diff --git a/api/v1/auth/main.py b/api/v1/auth/main.py index 7f4e5854..c53fe9b9 100755 --- a/api/v1/auth/main.py +++ b/api/v1/auth/main.py @@ -8,6 +8,7 @@ from email.message import EmailMessage from enum import Enum from functools import wraps +from logging import error from typing import Any, Awaitable, Callable, Optional import aiosmtplib @@ -26,6 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from db import get_db +from lib.hackatime import get_account from models.user import User dotenv.load_dotenv() @@ -253,8 +255,8 @@ async def send_otp_code(to_email: str, old_email: Optional[str] = None) -> bool: use_tls=True, ) except Exception as e: - print(f"Error sending OTP email: {e}") - raise HTTPException(status_code=500) from e + error("Error sending OTP email:", exc_info=e) + raise HTTPException(status_code=500, detail="Error sending OTP email") from e return True @@ -321,16 +323,35 @@ async def validate_otp( return Response(status_code=500) else: # new user flow - user = User(email=otp_client_response.email) + hackatime_data = None + try: + hackatime_data = get_account(otp_client_response.email) + except Exception: # type: ignore # pylint: disable=broad-exception-caught + pass # unable to fetch hackatime data, continue anyway + user = User( + email=otp_client_response.email, + hackatime_id=hackatime_data.id if hackatime_data else None, + username=hackatime_data.username if hackatime_data else None, + ) try: session.add(user) await session.commit() await session.refresh(user) except IntegrityError as e: await session.rollback() + if "email" in str(e.orig).lower(): + raise HTTPException( + status_code=409, + detail="User with this email already exists", + ) from e + if "hackatime_id" in str(e.orig).lower(): + raise HTTPException( + status_code=409, + detail="User with this hackatime_id already exists", + ) from e raise HTTPException( status_code=409, - detail="User already exists", + detail="User integrity error", ) from e except Exception: # type: ignore # pylint: disable=broad-exception-caught return Response(status_code=500) diff --git a/api/v1/projects/main.py b/api/v1/projects/main.py index ca3bee1e..9dead7ac 100755 --- a/api/v1/projects/main.py +++ b/api/v1/projects/main.py @@ -4,18 +4,20 @@ # import asyncpg # import orjson from datetime import datetime +from logging import error from typing import List, Optional import sqlalchemy +import validators from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response -from pydantic import BaseModel, ConfigDict, HttpUrl +from pydantic import BaseModel, ConfigDict, Field, HttpUrl from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -import validators from api.v1.auth import require_auth # type: ignore from db import get_db # , engine +from lib.hackatime import get_projects from models.user import User, UserProject CDN_HOST = "hc-cdn.hel1.your-objectstorage.com" @@ -46,6 +48,12 @@ class Config: extra = "forbid" +class HackatimeProject(BaseModel): + """Hackatime project linking request""" + + name: str = Field(min_length=1) + + class ProjectResponse(BaseModel): """Public representation of a project""" @@ -83,16 +91,16 @@ def from_model(cls, project: UserProject) -> "ProjectResponse": def validate_repo(repo: HttpUrl | None): """Validate repository URL against security criteria""" if not repo: - raise HTTPException(status_code=400, detail="repo url is missing") + raise HTTPException(status_code=400, detail="Repo url is missing") if not repo.host: - raise HTTPException(status_code=400, detail="repo url is missing host") + raise HTTPException(status_code=400, detail="Repo url is missing host") if not validators.url(str(repo), private=False): raise HTTPException( - status_code=400, detail="repo url is not valid or is local/private" + status_code=400, detail="Repo url is not valid or is local/private" ) if len(repo.host) > 256: raise HTTPException( - status_code=400, detail="repo url host exceeds the length limit" + status_code=400, detail="Repo url host exceeds the length limit" ) return True @@ -126,7 +134,7 @@ async def update_project( if project_request.preview_image is not None: if project_request.preview_image.host != CDN_HOST: raise HTTPException( - status_code=400, detail="image must be hosted on the Hack Club CDN" + status_code=400, detail="Image must be hosted on the Hack Club CDN" ) project.preview_image = str(project_request.preview_image) @@ -134,7 +142,7 @@ async def update_project( if project_request.demo_url is not None: if not validators.url(str(project_request.demo_url), private=False): raise HTTPException( - status_code=400, detail="demo url is not valid or is local/private" + status_code=400, detail="Demo url is not valid or is local/private" ) project.demo_url = str(project_request.demo_url) @@ -197,15 +205,93 @@ async def return_project_by_id( project = project_raw.scalar_one_or_none() if project is None: return Response(status_code=404) + return ProjectResponse.from_model(project) -@router.get("/{project_id}/model-test") +@router.post("/{project_id}/hackatime") @require_auth -async def model_test( - request: Request, project_id: int, session: AsyncSession = Depends(get_db) +async def link_hackatime_project( + request: Request, + project_id: int, + hackatime_project: HackatimeProject, + session: AsyncSession = Depends(get_db), ): - """Return a project by ID for a given user""" + """Link a Hackatime project to a user project""" + user_email = request.state.user["sub"] + + project_raw = await session.execute( + sqlalchemy.select(UserProject).where( + UserProject.id == project_id, UserProject.user_email == user_email + ) + ) + + project = project_raw.scalar_one_or_none() + if project is None: + return Response(status_code=404) + + if hackatime_project.name in project.hackatime_projects: + raise HTTPException( + status_code=400, detail="Hackatime project already linked to this project" + ) + + user_raw = await session.execute( + sqlalchemy.select(User) + .where(User.email == user_email) + .options(selectinload(User.projects)) + ) + + user = user_raw.scalar_one_or_none() + if user is None or not user.hackatime_id: + raise HTTPException( + status_code=400, detail="User does not have a linked Hackatime ID" + ) + + try: + user_projects = get_projects( + user.hackatime_id, project.hackatime_projects + [hackatime_project.name] + ) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + error("Error fetching Hackatime projects:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error fetching Hackatime projects" + ) from e + + if user_projects == {}: + raise HTTPException(status_code=400, detail="User has no Hackatime projects") + + if hackatime_project.name not in user_projects: + raise HTTPException( + status_code=400, detail="Hackatime project not found for this user" + ) + + project.hackatime_projects = project.hackatime_projects + [hackatime_project.name] + + values = user_projects.values() + total_seconds = sum(v for v in values if v is not None) + project.hackatime_total_hours = total_seconds / 3600.0 # convert to hours + + try: + await session.commit() + await session.refresh(project) + return ProjectResponse.from_model(project) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + await session.rollback() + error("Error linking Hackatime project:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error linking Hackatime project" + ) from e + + +@router.delete("/{project_id}/hackatime") +@require_auth +async def unlink_hackatime_project( + request: Request, + project_id: int, + hackatime_project: HackatimeProject, + session: AsyncSession = Depends(get_db), +): + """Unlink a Hackatime project from a user project""" user_email = request.state.user["sub"] project_raw = await session.execute( @@ -217,7 +303,51 @@ async def model_test( project = project_raw.scalar_one_or_none() if project is None: return Response(status_code=404) - return project.update_hackatime() + + if hackatime_project.name not in project.hackatime_projects: + raise HTTPException( + status_code=400, detail="Hackatime project not linked to this project" + ) + + user_raw = await session.execute( + sqlalchemy.select(User) + .options(selectinload(User.projects)) + .where(User.email == user_email) + ) + + user = user_raw.scalar_one_or_none() + if user is None or not user.hackatime_id: + raise HTTPException( + status_code=400, detail="User does not have a linked Hackatime ID" + ) + + old_projects = project.hackatime_projects + new_projects = [name for name in old_projects if name != hackatime_project.name] + + try: + user_projects = get_projects(user.hackatime_id, new_projects) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + error("Error fetching Hackatime projects:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error fetching Hackatime projects" + ) from e + + values = user_projects.values() + total_seconds = sum(v for v in values if v is not None) + project.hackatime_total_hours = total_seconds / 3600.0 # convert to hours + + project.hackatime_projects = new_projects + + try: + await session.commit() + await session.refresh(project) + return ProjectResponse.from_model(project) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + await session.rollback() + error("Error unlinking Hackatime project:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error unlinking Hackatime project" + ) from e @router.post("/") @@ -281,5 +411,5 @@ async def create_project( return ProjectResponse.from_model(new_project) except Exception as e: # type: ignore # pylint: disable=broad-exception-caught await session.rollback() - print(e) - return Response(status_code=500) + error("Error creating new project:", exc_info=e) + raise HTTPException(status_code=500, detail="Error creating new project") diff --git a/api/v1/users/main.py b/api/v1/users/main.py index 95b98535..d8d51309 100755 --- a/api/v1/users/main.py +++ b/api/v1/users/main.py @@ -5,6 +5,8 @@ # import asyncpg # import orjson from datetime import datetime, timedelta, timezone +from logging import error +from typing import Optional import sqlalchemy import validators @@ -13,10 +15,11 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -# from sqlalchemy.orm import selectinload from api.v1.auth.main import require_auth, send_otp_code # type: ignore from db import get_db +from lib.hackatime import get_account, get_projects from models.user import User router = APIRouter() @@ -27,6 +30,8 @@ class UserResponse(BaseModel): id: int email: str + username: Optional[str] = None + hackatime_id: Optional[int] = None permissions: list[int] marked_for_deletion: bool @@ -113,6 +118,8 @@ async def get_user( id=user.id, email=user.email, permissions=user.permissions, + username=user.username, + hackatime_id=user.hackatime_id, marked_for_deletion=user.marked_for_deletion, ) @@ -165,9 +172,133 @@ async def delete_user( ) -# disabled for 30 days, no login -> delete +@router.post("/recalculate_time") +@require_auth +async def recalculate_hackatime_time( + request: Request, + session: AsyncSession = Depends(get_db), +): + """Recalculate Hackatime time for a user""" + user_email = request.state.user["sub"] + + user_raw = await session.execute( + sqlalchemy.select(User) + .options(selectinload(User.projects)) + .where(User.email == user_email) + ) + + user = user_raw.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=404, detail="User not found" + ) # user doesn't exist + + if not user.hackatime_id: + raise HTTPException( + status_code=400, detail="User does not have a linked Hackatime ID" + ) + if not user.projects: + raise HTTPException(status_code=400, detail="User has no linked projects") + + if datetime.now(timezone.utc) - user.hackatime_last_fetched < timedelta(minutes=5): + raise HTTPException( + status_code=429, detail="Please wait before trying to recalculate again." + ) + + all_hackatime_projects: "set[str]" = set() + for project in user.projects: + if project.hackatime_projects: + all_hackatime_projects.update(project.hackatime_projects) + + try: + user_projects = get_projects(user.hackatime_id, list(all_hackatime_projects)) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + error("Error fetching Hackatime projects:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error fetching Hackatime projects" + ) from e + + for project in user.projects: + # find matching project from hackatime data + hackatime_projects = project.hackatime_projects + projects = [ + (name, seconds) + for name, seconds in user_projects.items() + if name in hackatime_projects + ] + + total_seconds = sum(float(seconds or 0) for _, seconds in projects) + project.hackatime_total_hours = total_seconds / 3600.0 + + user.hackatime_last_fetched = datetime.now(timezone.utc) + + try: + await session.commit() + await session.refresh(user) + return Response(status_code=204) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + await session.rollback() + error("Error updating Hackatime data:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error updating Hackatime data" + ) from e + +@router.get("/retry_hackatime_link") +@require_auth +async def retry_hackatime_link( + request: Request, + session: AsyncSession = Depends(get_db), +): + """Retry linking Hackatime account for a user""" + user_email = request.state.user["sub"] + + user_raw = await session.execute( + sqlalchemy.select(User).where(User.email == user_email) + ) + + user = user_raw.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=404, detail="User not found" + ) # user doesn't exist + + if user.hackatime_id: + raise HTTPException( + status_code=400, detail="User already has a linked Hackatime ID" + ) + + hackatime_data = None + try: + hackatime_data = get_account(user_email) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + error("Error fetching Hackatime account data:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error fetching Hackatime account data" + ) from e + + if not hackatime_data: + raise HTTPException(status_code=404, detail="Hackatime account not found") + + user.hackatime_id = hackatime_data.id + user.username = hackatime_data.username + + try: + await session.commit() + await session.refresh(user) + return Response(status_code=204) + except Exception as e: # type: ignore # pylint: disable=broad-exception-caught + await session.rollback() + error("Error linking Hackatime account:", exc_info=e) + raise HTTPException( + status_code=500, detail="Error linking Hackatime account" + ) from e + + +# disabled for 30 days, no login -> delete # @protect async def is_pending_deletion(): """Check if a user account is pending deletion""" diff --git a/db/main.py b/db/main.py index 8e4f07f9..de5ed2d3 100755 --- a/db/main.py +++ b/db/main.py @@ -8,9 +8,12 @@ dotenv.load_dotenv() +log_level = os.getenv("LOG_LEVEL", "INFO").upper() +connection_str = os.getenv("SQL_CONNECTION_STR", "") + engine = create_async_engine( - url=os.getenv("SQL_CONNECTION_STR", ""), - echo=True, + url=connection_str, + echo=log_level == "DEBUG", pool_pre_ping=True, pool_size=10, max_overflow=20, diff --git a/lib/hackatime.py b/lib/hackatime.py index 18a046d7..53e4495c 100644 --- a/lib/hackatime.py +++ b/lib/hackatime.py @@ -2,6 +2,7 @@ import os from typing import Dict, List, Optional +from logging import warning import requests import validators @@ -39,7 +40,7 @@ def get_account(email: str) -> Optional[HackatimeAccountResponse]: """ if not HACKATIME_API_KEY: - print("HACKATIME_API_KEY not set, returning mock data") + warning("HACKATIME_API_KEY not set, returning mock data") return HackatimeAccountResponse(id=1, username="TestUser") if not validators.email(email): @@ -118,6 +119,9 @@ def get_projects( seconds spent (None if not found). """ + if projects_filter is not None and len(projects_filter) == 0: + return {} + response = requests.get( f"{HACKATIME_API_URL}/users/{user}/stats", params={"features": "projects", "start_date": CUTOFF_DATE}, diff --git a/main.py b/main.py index b98743b2..93affbc6 100755 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ # import asyncpg from contextlib import asynccontextmanager from typing import Any +from logging import basicConfig +import os # import orjson # import os @@ -34,9 +36,11 @@ # from api.users import foo - dotenv.load_dotenv() +log_level = os.getenv("LOG_LEVEL", "INFO").upper() +basicConfig(level=log_level) + # engine = create_async_engine( # url=os.getenv("SQL_CONNECTION_STR", ""), # echo=True, diff --git a/models/user.py b/models/user.py index f5df85d5..89f9e3c7 100755 --- a/models/user.py +++ b/models/user.py @@ -34,6 +34,12 @@ class User(Base): permissions: Mapped[list[int]] = MappedColumn( ARRAY(SmallInteger), nullable=False, server_default="{}" ) + hackatime_id: Mapped[Optional[int]] = MappedColumn( + Integer, nullable=True, unique=True, default=None + ) + username: Mapped[Optional[str]] = MappedColumn( + String, nullable=True, unique=False, default=None + ) projects: Mapped[list["UserProject"]] = relationship( "UserProject", back_populates="user", cascade="all, delete-orphan" ) @@ -43,6 +49,11 @@ class User(Base): date_for_deletion: Mapped[Optional[datetime]] = MappedColumn( DateTime(timezone=True), nullable=True, default=None ) + hackatime_last_fetched: Mapped[datetime] = MappedColumn( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) class UserProject(Base): @@ -73,6 +84,3 @@ class UserProject(Base): # Relationship back to user user: Mapped["User"] = relationship("User", back_populates="projects") - - def update_hackatime(self): - return self.hackatime_projects