Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,16 @@ def system():
UserAllAPI,
UserTokenExpiryAPI,
UserRoleApi,
UserInfoAPI,
UserRecentActivityAPI,
)
from backend.api.translate import TranslateTextAPI
from backend.api.statistics import (
UserStatSAPI,
ChallengeContributorsStatsAPI,
HomeStatsAPI,
UserLeaderboardAPI,
UserOSMStatsAPI,
)

api.add_resource(HomeStatsAPI, "/stats/home/")
Expand Down Expand Up @@ -134,6 +137,12 @@ def system():
api.add_resource(UserAllAPI, "/users/")
api.add_resource(UserRoleApi, "/user/<int:user_id>/update/role/<int:role>/")
api.add_resource(UserStatSAPI, "/user/<int:user_id>/stats/")
api.add_resource(UserOSMStatsAPI, "/user/<int:user_id>/osm-stats/")
api.add_resource(UserInfoAPI, "/user/<string:username>/")
api.add_resource(
UserRecentActivityAPI, "/user/<int:user_id>/recent-activity/"
)

api.add_resource(
ChallengeContributorsStatsAPI, "/challenge/<int:challenge_id>/user-stats/"
)
Expand Down
5 changes: 5 additions & 0 deletions backend/api/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions backend/api/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions backend/models/dtos/stats_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
64 changes: 61 additions & 3 deletions backend/services/stats_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
32 changes: 32 additions & 0 deletions backend/services/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -46,6 +47,7 @@ function App() {
{error && <ShowError error={error} setError={setError} />}
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/profile/:username" element={<UserProfile />} />
<Route path="/challenges" element={<ChallengesView />} />
<Route path="/manage" element={<ManagementSection />}>
<Route path="challenge/:id" element={<UpdateChallengeView />} />
Expand Down
64 changes: 31 additions & 33 deletions frontend/src/components/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,41 +115,39 @@ const NavBarLarge = () => {
const navClassActive =
navClass + " border-bottom border-2 border-secondary active";
return (
<>
<div className="collapse navbar-collapse d-flex" id="navbarText">
<ul className="navbar-nav me-auto flex-grow-1 d-flex justify-content-center">
{menuItems.map((item) => (
<li className="nav-item" key={item.link}>
<NavLink
className={({ isActive }) =>
isActive ? navClassActive : navClass
}
to={"/" + item.link}
>
<span className="pb-1">{item.label}</span>
</NavLink>
</li>
))}
<li className="nav-item">
<a
className={navClass}
href="https://forms.gle/fmfeyPEXjSPZk1tX6"
target="_blank"
rel="noopener noreferrer"
<div className="collapse navbar-collapse d-flex" id="navbarText">
<ul className="navbar-nav me-auto flex-grow-1 d-flex justify-content-center">
{menuItems.map((item) => (
<li className="nav-item" key={item.link}>
<NavLink
className={({ isActive }) =>
isActive ? navClassActive : navClass
}
to={"/" + item.link}
>
Report
<i
className="fa fa-external-link ms-1"
style={{ fontSize: "0.8rem" }}
></i>
</a>
<span className="pb-1">{item.label}</span>
</NavLink>
</li>
</ul>
<div>
<Login />
</div>
))}
<li className="nav-item">
<a
className={navClass}
href="https://forms.gle/fmfeyPEXjSPZk1tX6"
target="_blank"
rel="noopener noreferrer"
>
Report
<i
className="fa fa-external-link ms-1"
style={{ fontSize: "0.8rem" }}
></i>
</a>
</li>
</ul>
<div>
<Login />
</div>
</>
</div>
);
};

Expand All @@ -174,7 +172,7 @@ function NavBar() {
style={{ width: "35px", height: "35px", borderRadius: "50%" }}
className="me-1"
/>
<span >OSMLocalizer</span>
<span>OSMLocalizer</span>
</NavLink>
{width > breakpoint ? <NavBarLarge /> : <NavBarSmall />}
</div>
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/login.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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),
});
Expand All @@ -92,8 +97,7 @@ const UserMenu = ({ username, user_picture, dispatch }) => {
<span onClick={onClick}> {username} </span>
</span>
<ul className="dropdown-menu d-flex flex-column mt-1 p-1 rounded-0 ">
{/* <li><a className="dropdown-item" href="">Profile</a></li>
<li><a className="dropdown-item" href="">Settings</a></li> */}
<li><span className="dropdown-item" onClick={navigateToProfile}>Profile</span></li>
<li>
<span
className="dropdown-item"
Expand Down
Loading