Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
29 changes: 25 additions & 4 deletions api/v1/auth/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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:", e)
raise HTTPException(status_code=500, detail="Error sending OTP email") from e

return True

Expand Down Expand Up @@ -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)
Expand Down
160 changes: 145 additions & 15 deletions api/v1/projects/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
# import orjson
from datetime import datetime
from typing import List, Optional
from logging import error

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"
Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -126,15 +134,15 @@ 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)

# Validate and update demo URL if being updated
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)

Expand Down Expand Up @@ -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:", 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:", 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(
Expand All @@ -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:", 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:", e)
raise HTTPException(
status_code=500, detail="Error unlinking Hackatime project"
) from e


@router.post("/")
Expand Down Expand Up @@ -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:", e)
return HTTPException(status_code=500, detail="Error creating new project")
Loading