diff --git a/backend/__init__.py b/backend/__init__.py index 0d57966..cd19747 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -86,6 +86,8 @@ def system(): UserAllAPI, UserTokenExpiryAPI, UserRoleApi, + UserInfoAPI, + UserRecentActivityAPI, ) from backend.api.translate import TranslateTextAPI from backend.api.statistics import ( @@ -93,6 +95,7 @@ def system(): ChallengeContributorsStatsAPI, HomeStatsAPI, UserLeaderboardAPI, + UserOSMStatsAPI, ) api.add_resource(HomeStatsAPI, "/stats/home/") @@ -134,6 +137,12 @@ def system(): api.add_resource(UserAllAPI, "/users/") api.add_resource(UserRoleApi, "/user//update/role//") api.add_resource(UserStatSAPI, "/user//stats/") + api.add_resource(UserOSMStatsAPI, "/user//osm-stats/") + api.add_resource(UserInfoAPI, "/user//") + api.add_resource( + UserRecentActivityAPI, "/user//recent-activity/" + ) + api.add_resource( ChallengeContributorsStatsAPI, "/challenge//user-stats/" ) diff --git a/backend/api/statistics.py b/backend/api/statistics.py index 126d118..d3d316f 100644 --- a/backend/api/statistics.py +++ b/backend/api/statistics.py @@ -21,6 +21,11 @@ def get(self, user_id): return StatsService.get_user_stats(user_id, challenge_id).dict() +class UserOSMStatsAPI(Resource): + def get(self, user_id): + return StatsService.get_user_osm_stats(user_id) + + class ChallengeContributorsStatsAPI(Resource): def get(self, challenge_id): start_date = request.args.get("startDate", None) diff --git a/backend/api/user.py b/backend/api/user.py index e8f02e9..fa6b501 100644 --- a/backend/api/user.py +++ b/backend/api/user.py @@ -35,6 +35,22 @@ def get(self): return UserService.login_user(user_info["user"], token["access_token"]).dict() +class UserInfoAPI(Resource): + @auth.login_required + def get(self, username): + user = UserService.get_user_by_username(username) + if user is None: + raise NotFound("USER_NOT_FOUND") + return user.as_dto().dict() + + +class UserRecentActivityAPI(Resource): + @auth.login_required + def get(self, user_id): + limit = request.args.get("limit", 5) + return UserService.get_user_recent_activity(user_id, limit) + + class UserTokenExpiryAPI(Resource): @auth.login_required def get(self): diff --git a/backend/models/dtos/stats_dto.py b/backend/models/dtos/stats_dto.py index 0d0e5f8..12cc462 100644 --- a/backend/models/dtos/stats_dto.py +++ b/backend/models/dtos/stats_dto.py @@ -18,6 +18,8 @@ class UserStatsDTO(BaseModel): total_invalidated_by_me: int total_my_validated: int total_my_invalidated: int + top_challenges_contributed: Optional[List[dict]] + total_contributions: Optional[int] class ListUserStatsDTO(BaseModel): diff --git a/backend/services/stats_service.py b/backend/services/stats_service.py index 895d327..882fd9c 100644 --- a/backend/services/stats_service.py +++ b/backend/services/stats_service.py @@ -33,11 +33,14 @@ def get_home_page_stats(): @staticmethod def get_user_challenegs_count(user_id: int): """Get number of hallenges the user has contributed to""" - return ( - Feature.query.filter_by(localized_by=user_id) + contributed_challenges_count = ( + Feature.query.filter( + or_(Feature.localized_by == user_id, Feature.validated_by == user_id) + ) .distinct(Feature.challenge_id) .count() ) + return contributed_challenges_count @staticmethod def get_user_stats_by_status( @@ -76,7 +79,7 @@ def get_user_stats_by_status( status=FeatureStatus.INVALIDATED.value, localized_by=user_id ) else: - raise Exception("Invalid action") + raise ValueError("Invalid action: {}".format(action)) if start_date: query = query.filter(Feature.last_updated >= start_date) if end_date: @@ -123,9 +126,37 @@ def get_user_stats( ) if not challenge_id: stats_dto.total_challenges = total_challenges + top_challenges_contributed, total_contributions = StatsService.get_top_challenges_contributed(user_id) + + stats_dto.top_challenges_contributed = top_challenges_contributed + stats_dto.total_contributions = total_contributions return stats_dto + @staticmethod + def get_top_challenges_contributed(user_id: int): + """Get top challenges contributed along with the number of contributions""" + + query = Feature.query.filter( + or_(Feature.localized_by == user_id, Feature.validated_by == user_id) + ) + challenges = ( + query.with_entities(Feature.challenge_id, Challenge.name) + .join(Challenge, Feature.challenge_id == Challenge.id) + .distinct(Feature.challenge_id) + .all() + ) + top_challenges = {} + for challenge in challenges: + challenge_id = challenge[0] + challenge_name = challenge[1] + count = query.filter_by(challenge_id=challenge_id).count() + top_challenges[challenge_name] = count + top_challenges = dict( + sorted(top_challenges.items(), key=lambda item: item[1], reverse=True) + ) + return top_challenges, sum(top_challenges.values()) + @staticmethod def get_all_challenge_contributors( challenge_id: int, start_date=None, end_date=None @@ -188,3 +219,30 @@ def get_user_leaderboard(): for user in users: user_stats.append(StatsService.get_user_stats(user[0])) return ListUserStatsDTO(users=user_stats) + + @staticmethod + def get_user_osm_stats(user_id: int): + """Get user osm stats in nodes, ways and relations""" + osm_stats = {"node": {}, "way": {}, "relation": {}} + for osm_type in osm_stats.keys(): + osm_stats[osm_type]["localized"] = ( + Feature.query.filter_by(localized_by=user_id) + .filter(Feature.osm_type == osm_type) + .count() + ) + for osm_type in osm_stats.keys(): + osm_stats[osm_type]["validated"] = ( + Feature.query.filter_by(validated_by=user_id) + .filter_by(status=FeatureStatus.VALIDATED.value) + .filter(Feature.osm_type == osm_type) + .count() + ) + + for osm_type in osm_stats.keys(): + osm_stats[osm_type]["invalidated"] = ( + Feature.query.filter_by(localized_by=user_id) + .filter_by(status=FeatureStatus.INVALIDATED.value) + .filter(Feature.osm_type == osm_type) + .count() + ) + return osm_stats diff --git a/backend/services/user_service.py b/backend/services/user_service.py index 1524485..10b84c4 100644 --- a/backend/services/user_service.py +++ b/backend/services/user_service.py @@ -2,9 +2,12 @@ from datetime import timedelta from flask import current_app from flask_httpauth import HTTPTokenAuth +from sqlalchemy import or_, and_ from backend.models.sql.user import User from backend.models.dtos.user_dto import UserLoginDTO, UserAllDTO +from backend.models.sql.enum import FeatureStatus +from backend.models.sql.features import Feature from backend.services.utills import timestamp from backend.errors import Unauthorized, NotFound @@ -98,3 +101,32 @@ def get_all_users(): """Get all users.""" users_list = [user.as_dto() for user in User.get_all()] return UserAllDTO(users=users_list).dict() + + @staticmethod + def get_user_by_username(username: str) -> User: + """Get a user by username.""" + user = User.get_by_username(username) + if user is None: + raise NotFound("USER_NOT_FOUND") + return user + + @staticmethod + def get_user_recent_activity(user_id: int, limit=5): + """Get recent activity for a user.""" + query = Feature.query.filter( + or_( + and_(Feature.localized_by == user_id, Feature.status == FeatureStatus.LOCALIZED.value), + and_(Feature.validated_by == user_id, Feature.status == FeatureStatus.VALIDATED.value) + ) + ) + recent_activity = query.order_by(Feature.last_updated.desc()).limit(limit).all() + recent_activity_list = [ + { + "feature_id": activity.id, + "challenge_id": activity.challenge_id, + "osm_type": activity.osm_type, + "status": FeatureStatus(activity.status).name.capitalize() + } + for activity in recent_activity + ] + return recent_activity_list diff --git a/frontend/package.json b/frontend/package.json index ebcd2b9..3f92079 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,12 +18,14 @@ "@turf/bbox": "^6.5.0", "@vitalets/google-translate-api": "^9.1.0", "bootstrap": "5.3.1", + "chart.js": "^4.4.4", "final-form": "^4.20.8", "maplibre-gl": "^4.0.2", "osm-auth": "^2.0.1", "osmtogeojson": "^3.0.0-beta.5", "prettier": "^3.0.2", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-detect-click-outside": "^1.1.7", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", diff --git a/frontend/src/App.js b/frontend/src/App.js index 452be2a..2429c9a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -12,6 +12,7 @@ import AuthorizedView from "./views/authorized"; import { handleLogin } from "./store/store"; import { LoginView } from "./views/login"; import { LoggedInRoute } from "./views/privateRoute"; +import UserProfile from "./views/profile"; import HomeView from "./views/home"; import ManagementSection from "./views/managementRoute"; import AboutView from "./views/about"; @@ -46,6 +47,7 @@ function App() { {error && } } /> + } /> } /> }> } /> diff --git a/frontend/src/components/header.js b/frontend/src/components/header.js index 0e95e55..3ad9745 100644 --- a/frontend/src/components/header.js +++ b/frontend/src/components/header.js @@ -115,41 +115,39 @@ const NavBarLarge = () => { const navClassActive = navClass + " border-bottom border-2 border-secondary active"; return ( - <> - ); }; @@ -174,7 +172,7 @@ function NavBar() { style={{ width: "35px", height: "35px", borderRadius: "50%" }} className="me-1" /> - OSMLocalizer + OSMLocalizer {width > breakpoint ? : } diff --git a/frontend/src/components/login.js b/frontend/src/components/login.js index cd80c60..3ccfd76 100644 --- a/frontend/src/components/login.js +++ b/frontend/src/components/login.js @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useDetectClickOutside } from "react-detect-click-outside"; import { fetchLocalJSONAPI } from "../utills/fetch"; @@ -7,7 +8,7 @@ import { useDispatch, useSelector } from "react-redux"; import { authActions } from "../store/store"; import userAvatar from "../assets/icons/user_avatar.png"; -const createPopup = (title = "Authentication", location) => { +const createPopup = (location, title = "Authentication") => { const width = 600; const height = 600; const left = window.innerWidth / 2 - width / 2; @@ -76,6 +77,10 @@ const UserMenu = ({ username, user_picture, dispatch }) => { const onClick = () => { setIsDropdownOpen(!isDropdownOpen); }; + const navigate = useNavigate(); + const navigateToProfile = () => { + navigate(`/profile/${username}`); + }; const ref = useDetectClickOutside({ onTriggered: () => setIsDropdownOpen(false), }); @@ -92,8 +97,7 @@ const UserMenu = ({ username, user_picture, dispatch }) => { {username}
    - {/*
  • Profile
  • -
  • Settings
  • */} +
  • Profile
  • { + return ( +
    +
    Top Challenges Contributed
    +
    + {userChallengeData ? ( +
      + {Object.keys(userChallengeData) + .slice(0, 5) + .map((challenge) => ( +
    • + {challenge} + + {userChallengeData[challenge]} Contributions + +
    • + ))} +
    + ) : ( +

    No recent contributions

    + )} +
    +
    + ); +}; + +export const ContributionRadarChart = ({ userStats }) => { + let contributions = [ + userStats?.total_skipped, + userStats?.total_validated_by_me + userStats?.total_invalidated_by_me, + userStats?.total_localized, + ]; + // Calculate percentage of contributions + const totalContributions = contributions.reduce((a, b) => a + b, 0); + contributions = contributions.map((value) => + ((value / totalContributions) * 100).toFixed(2) + ); + const data = { + labels: ["Marked Invalid/Skipped", "Validated", "Localized"], + datasets: [ + { + label: "Contributions", + data: contributions, + backgroundColor: "rgba(168,225,178, 0.7)", + borderColor: "rgba(57,211,84, 1)", + borderWidth: 0.1, + pointBackgroundColor: "#fff", + pointBorderColor: "rgb(33,109,57)", + pointBorderWidth: 2, + }, + ], + }; + + // Options for the radar chart + const options = { + scales: { + r: { + beginAtZero: true, + grid: { + display: true, + }, + angleLines: { + display: true, + color: "rgb(33,109,57)", + lineWidth: 2, + }, + ticks: { + display: false, + }, + pointLabels: { + callback: function (label, index) { + // Display value and label on separate lines + const value = data.datasets[0].data[index]; + return [`${value}%`, label]; + }, + font: { + size: 11, + }, + align: "end", + }, + title: { + display: false, + }, + }, + }, + + plugins: { + legend: { + display: false, + }, + }, + }; + + return ( +
    +
    Contribution Summary
    +
    + +
    +
    + ); +}; + +export const UserInfoSection = ({ + userInfo, + totalContributions, + isMyProfile, +}) => { + const registered_date = new Date( + userInfo?.date_registered + ).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + return ( +
    +
    + User Profile +

    {userInfo?.username}

    +

    Contributor since: {registered_date}

    +

    Total Contributions: {totalContributions}

    +
    + {isMyProfile && } +
    + ); +}; + + +const UserEditProfile = ({ userInfo }) => { + const [email, setEmail] = useState(userInfo?.email); + const [editEmail, setEditEmail] = useState(true); + + const onEmailChange = (e) => { + setEmail(e.target.value); + if (e.target.value !== userInfo?.email) { + setEditEmail(true); + } + }; + const onEditEmail = () => { + setEditEmail(!editEmail); + }; + return ( +
    +
    + {editEmail || userInfo?.email == null ? ( + onEmailChange(e)} + /> + ) : ( + {userInfo?.email} + )} + {editEmail ? ( +
    + + +
    + ) : ( + + )} +
    +
    + ); +}; + + +const UserStatsCard = ({ type, localized, validated, invalidated }) => { + const statTypesIcons = { + node: "node-plus", + way: "diagram-2", + relation: "bounding-box", + }; + const icon = statTypesIcons[type]; + return ( +
    +
    +
    + +
    +
    +
    + + {localized} + + {type}s localized +
    + +
    +
    +
    + {validated} + Valid +
    +
    +
    + {invalidated} + Invalid +
    +
    +
    + + {localized - validated - invalidated} + + Unchecked +
    +
    +
    +
    +
    + ); +}; + + +export const UserStatsSection = ({ osmStats }) => { + const types = ["node", "way", "relation"]; + + return ( +
    +
    Contributions
    +
    + {/*
    Contributions
    */} +
    + {types.map((type) => ( + + ))} +
    +
    +
    + ); +}; + + +export const UserRecentActivity = ({ recentActivity }) => { + const activity_type_icons = { + Localized: "bi bi-pencil-square", + Validated: "bi bi-check2-circle", + }; + return ( +
    +
    Recent Activity
    +
    + {recentActivity ? ( + + ) : ( +

    No recent activity

    + )} +
    +
    + ); +}; + +ContributionRadarChart.propTypes = { + userStats: PropTypes.shape({ + total_skipped: PropTypes.number, + total_validated_by_me: PropTypes.number, + total_invalidated_by_me: PropTypes.number, + total_localized: PropTypes.number, + }), +}; + +TopContributedProjects.propTypes = { + userChallengeData: PropTypes.arrayOf(PropTypes.array), +}; + +UserInfoSection.propTypes = { + userInfo: PropTypes.shape({ + picture_url: PropTypes.string, + username: PropTypes.string, + date_registered: PropTypes.string, + }), + totalContributions: PropTypes.number, + isMyProfile: PropTypes.bool, +}; + +UserEditProfile.propTypes = { + userInfo: PropTypes.shape({ + email: PropTypes.string, + }), +}; + +UserStatsCard.propTypes = { + type: PropTypes.string, + localized: PropTypes.number, + validated: PropTypes.number, + invalidated: PropTypes.number, +}; + +UserStatsSection.propTypes = { + osmStats: PropTypes.shape({ + node: PropTypes.object, + way: PropTypes.object, + relation: PropTypes.object, + }), +}; + +UserRecentActivity.propTypes = { + recentActivity: PropTypes.arrayOf(PropTypes.object), +}; diff --git a/frontend/src/views/profile.js b/frontend/src/views/profile.js new file mode 100644 index 0000000..6616ef2 --- /dev/null +++ b/frontend/src/views/profile.js @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router-dom"; + +import { + ContributionRadarChart, + UserInfoSection, + UserStatsSection, + UserRecentActivity, + TopContributedProjects, +} from "../components/userProfileComponents"; +import { fetchLocalJSONAPI } from "../utills/fetch"; +import "bootstrap/dist/css/bootstrap.min.css"; + +const UserProfile = () => { + const { username } = useParams(); + const user = useSelector((state) => state.auth.user); + + const ismMyProfile = user?.username === username; + + const [userInfo, setUserInfo] = useState({}); + const [userStats, setUserStats] = useState({}); + const [osmStats, setOsmStats] = useState({}); + const [recentActivity, setRecentActivity] = useState([]); + const [loading, setLoading] = useState(true); + const token = localStorage.getItem("jwt_token"); + + useEffect(() => { + const fetchData = async () => { + try { + const userData = await fetchLocalJSONAPI( + `user/${username}/`, + token, + "GET" + ); + setUserInfo(userData); + + if (userData?.id) { + const [statsData, osmStatsData, recentActivityData] = + await Promise.all([ + fetchLocalJSONAPI(`user/${userData.id}/stats/`, token, "GET"), + fetchLocalJSONAPI(`user/${userData.id}/osm-stats/`, token, "GET"), + fetchLocalJSONAPI( + `user/${userData.id}/recent-activity/`, + token, + "GET" + ), + ]); + setUserStats(statsData); + setOsmStats(osmStatsData); + setRecentActivity(recentActivityData); + } + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [username, token]); + + if (loading) { + return
    Loading...
    ; + } + + return ( +
    +
    + +
    + {userStats && ( +
    + +
    + + +
    + +
    + )} +
    + ); +}; + +export default UserProfile; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b5d233a..65d4da6 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1644,6 +1644,11 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@kurkle/color@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f" + integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz" @@ -3551,6 +3556,13 @@ char-regex@^2.0.0: resolved "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz" integrity sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw== +chart.js@^4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.4.tgz#b682d2e7249f7a0cbb1b1d31c840266ae9db64b7" + integrity sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA== + dependencies: + "@kurkle/color" "^0.3.0" + check-types@^11.1.1: version "11.2.2" resolved "https://registry.npmjs.org/check-types/-/check-types-11.2.2.tgz" @@ -8271,6 +8283,11 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" whatwg-fetch "^3.6.2" +react-chartjs-2@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz#43c1e3549071c00a1a083ecbd26c1ad34d385f5d" + integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA== + react-detect-click-outside@^1.1.7: version "1.1.7" resolved "https://registry.npmjs.org/react-detect-click-outside/-/react-detect-click-outside-1.1.7.tgz"