Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .dockerignore +9 -0
- Dockerfile +12 -0
- README.md +5 -7
- app/__init__.py +0 -0
- app/application/__init__.py +0 -0
- app/application/interfaces/__init__.py +0 -0
- app/application/interfaces/jwt.py +11 -0
- app/application/interfaces/password.py +11 -0
- app/application/use_cases/__init__.py +0 -0
- app/application/use_cases/auth_use_case.py +81 -0
- app/application/use_cases/student_use_case.py +493 -0
- app/config.py +17 -0
- app/core/__init__.py +0 -0
- app/core/logging.py +8 -0
- app/db/__init__.py +0 -0
- app/db/base.py +5 -0
- app/db/session.py +19 -0
- app/domain/__init__.py +0 -0
- app/domain/entities/__init__.py +0 -0
- app/domain/entities/user.py +18 -0
- app/domain/repositories/__init__.py +0 -0
- app/domain/repositories/user_repository.py +17 -0
- app/infrastructure/__init__.py +0 -0
- app/infrastructure/persistence/__init__.py +0 -0
- app/infrastructure/persistence/models/__init__.py +0 -0
- app/infrastructure/persistence/models/student.py +25 -0
- app/infrastructure/persistence/models/user.py +15 -0
- app/infrastructure/persistence/repositories/__init__.py +0 -0
- app/infrastructure/persistence/repositories/user_repository.py +43 -0
- app/infrastructure/security/__init__.py +0 -0
- app/infrastructure/security/jwt.py +26 -0
- app/infrastructure/security/password.py +11 -0
- app/infrastructure/storage/__init__.py +0 -0
- app/infrastructure/storage/hf_storage.py +46 -0
- app/main.py +101 -0
- app/presentation/__init__.py +0 -0
- app/presentation/dependencies.py +57 -0
- app/presentation/routers/__init__.py +0 -0
- app/presentation/routers/auth.py +69 -0
- app/presentation/routers/students.py +107 -0
- app/presentation/schemas/__init__.py +0 -0
- app/presentation/schemas/auth.py +21 -0
- app/presentation/schemas/response.py +100 -0
- app/presentation/schemas/student.py +46 -0
- migrate.py +52 -0
- pyproject.toml +25 -0
- requirements.txt +11 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
.git
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
bukang.db
|
| 8 |
+
.pytest_cache
|
| 9 |
+
.venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
| 1 |
---
|
| 2 |
title: Bukang Backend
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
title: Bukang Backend
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
|
|
|
|
|
app/__init__.py
ADDED
|
File without changes
|
app/application/__init__.py
ADDED
|
File without changes
|
app/application/interfaces/__init__.py
ADDED
|
File without changes
|
app/application/interfaces/jwt.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class JWTService(ABC):
|
| 5 |
+
@abstractmethod
|
| 6 |
+
def create_access_token(self, data: dict) -> str:
|
| 7 |
+
...
|
| 8 |
+
|
| 9 |
+
@abstractmethod
|
| 10 |
+
def decode_token(self, token: str) -> dict:
|
| 11 |
+
...
|
app/application/interfaces/password.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class PasswordService(ABC):
|
| 5 |
+
@abstractmethod
|
| 6 |
+
def hash(self, password: str) -> str:
|
| 7 |
+
...
|
| 8 |
+
|
| 9 |
+
@abstractmethod
|
| 10 |
+
def verify(self, plain: str, hashed: str) -> bool:
|
| 11 |
+
...
|
app/application/use_cases/__init__.py
ADDED
|
File without changes
|
app/application/use_cases/auth_use_case.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
from app.domain.entities.user import User
|
| 4 |
+
from app.domain.repositories.user_repository import UserRepository
|
| 5 |
+
from app.application.interfaces.jwt import JWTService
|
| 6 |
+
from app.application.interfaces.password import PasswordService
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class RegisterRequest:
|
| 11 |
+
username: str
|
| 12 |
+
password: str
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class LoginRequest:
|
| 17 |
+
username: str
|
| 18 |
+
password: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class TokenResult:
|
| 23 |
+
access_token: str
|
| 24 |
+
token_type: str = "bearer"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class UserResult:
|
| 29 |
+
id: str
|
| 30 |
+
username: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AuthUseCase:
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
user_repository: UserRepository,
|
| 37 |
+
password_service: PasswordService,
|
| 38 |
+
jwt_service: JWTService,
|
| 39 |
+
):
|
| 40 |
+
self._user_repo = user_repository
|
| 41 |
+
self._password = password_service
|
| 42 |
+
self._jwt = jwt_service
|
| 43 |
+
|
| 44 |
+
def register(self, request: RegisterRequest) -> UserResult:
|
| 45 |
+
existing = self._user_repo.find_by_username(request.username)
|
| 46 |
+
if existing is not None:
|
| 47 |
+
raise ValueError("Username already taken")
|
| 48 |
+
|
| 49 |
+
import uuid
|
| 50 |
+
user_id = str(uuid.uuid4())
|
| 51 |
+
hashed = self._password.hash(request.password)
|
| 52 |
+
user = User.create(
|
| 53 |
+
username=request.username,
|
| 54 |
+
hashed_password=hashed,
|
| 55 |
+
user_id=user_id,
|
| 56 |
+
)
|
| 57 |
+
self._user_repo.save(user)
|
| 58 |
+
return UserResult(id=user.id, username=user.username)
|
| 59 |
+
|
| 60 |
+
def login(self, request: LoginRequest) -> TokenResult:
|
| 61 |
+
user = self._user_repo.find_by_username(request.username)
|
| 62 |
+
if user is None:
|
| 63 |
+
raise ValueError("Invalid username or password")
|
| 64 |
+
|
| 65 |
+
if not self._password.verify(request.password, user.hashed_password):
|
| 66 |
+
raise ValueError("Invalid username or password")
|
| 67 |
+
|
| 68 |
+
token = self._jwt.create_access_token({"sub": user.id})
|
| 69 |
+
return TokenResult(access_token=token)
|
| 70 |
+
|
| 71 |
+
def verify_token(self, token: str) -> User:
|
| 72 |
+
payload = self._jwt.decode_token(token)
|
| 73 |
+
user_id: str | None = payload.get("sub")
|
| 74 |
+
if user_id is None:
|
| 75 |
+
raise ValueError("Invalid token")
|
| 76 |
+
|
| 77 |
+
user = self._user_repo.find_by_id(user_id)
|
| 78 |
+
if user is None:
|
| 79 |
+
raise ValueError("Invalid token")
|
| 80 |
+
|
| 81 |
+
return user
|
app/application/use_cases/student_use_case.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException, UploadFile, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.infrastructure.persistence.models.student import StudentModel as Student
|
| 5 |
+
from app.infrastructure.storage.hf_storage import storage_service
|
| 6 |
+
from app.presentation.schemas.student import SubmissionRequest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def save_photo(file: UploadFile) -> str:
|
| 10 |
+
content = file.file.read()
|
| 11 |
+
return storage_service.upload_photo(content, file.filename or "photo.jpg")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_submission(db: Session, user_id: str, data: SubmissionRequest) -> Student:
|
| 15 |
+
name = find_name_from_nrp(data.nrp)
|
| 16 |
+
if data.nrp[2] == "2" and data.nrp[3] == "5":
|
| 17 |
+
major = "Teknik Informatika"
|
| 18 |
+
elif data.nrp[2] == "5" and data.nrp[3] == "3":
|
| 19 |
+
major = "Rekayasa Perangkat Lunak"
|
| 20 |
+
elif data.nrp[2] == "5" and data.nrp[3] == "4":
|
| 21 |
+
major = "Rekayasa Kecerdasan Artifisial"
|
| 22 |
+
else:
|
| 23 |
+
major = "Unknown"
|
| 24 |
+
|
| 25 |
+
student = Student(
|
| 26 |
+
user_id=user_id,
|
| 27 |
+
nrp=data.nrp,
|
| 28 |
+
name=name,
|
| 29 |
+
major=major,
|
| 30 |
+
hometown=data.asal_daerah,
|
| 31 |
+
hobbies=",".join(data.hobi),
|
| 32 |
+
first_impression=data.first_impression,
|
| 33 |
+
longitude=data.longitude,
|
| 34 |
+
latitude=data.latitude,
|
| 35 |
+
captured_at=data.captured_at,
|
| 36 |
+
photo_url=data.photo_url,
|
| 37 |
+
)
|
| 38 |
+
db.add(student)
|
| 39 |
+
db.commit()
|
| 40 |
+
db.refresh(student)
|
| 41 |
+
return student
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def resolve_nrp_data(nrp: str, db: Session | None = None) -> dict:
|
| 45 |
+
if db is not None:
|
| 46 |
+
existing = db.query(Student).filter(Student.nrp == nrp).first()
|
| 47 |
+
if existing is not None:
|
| 48 |
+
return {"name": existing.name, "major": existing.major}
|
| 49 |
+
|
| 50 |
+
name = find_name_from_nrp(nrp)
|
| 51 |
+
if len(nrp) >= 4:
|
| 52 |
+
if nrp[2] == "2" and nrp[3] == "5":
|
| 53 |
+
major = "Teknik Informatika"
|
| 54 |
+
elif nrp[2] == "5" and nrp[3] == "3":
|
| 55 |
+
major = "Rekayasa Perangkat Lunak"
|
| 56 |
+
elif nrp[2] == "5" and nrp[3] == "4":
|
| 57 |
+
major = "Rekayasa Kecerdasan Artifisial"
|
| 58 |
+
else:
|
| 59 |
+
major = "Unknown"
|
| 60 |
+
else:
|
| 61 |
+
major = "Unknown"
|
| 62 |
+
return {"name": name, "major": major}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_students(db: Session, user_id: str) -> list[Student]:
|
| 66 |
+
return (
|
| 67 |
+
db.query(Student)
|
| 68 |
+
.filter(Student.user_id == user_id)
|
| 69 |
+
.order_by(Student.created_at.desc())
|
| 70 |
+
.all()
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_student(db: Session, user_id: str, student_id: str) -> Student:
|
| 75 |
+
student = (
|
| 76 |
+
db.query(Student)
|
| 77 |
+
.filter(Student.id == student_id, Student.user_id == user_id)
|
| 78 |
+
.first()
|
| 79 |
+
)
|
| 80 |
+
if not student:
|
| 81 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found")
|
| 82 |
+
return student
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def search_students_by_nrp(db: Session, user_id: str, nrp: str) -> list[Student]:
|
| 86 |
+
return (
|
| 87 |
+
db.query(Student)
|
| 88 |
+
.filter(Student.user_id == user_id, Student.nrp.like(f"%{nrp}%"))
|
| 89 |
+
.order_by(Student.nrp)
|
| 90 |
+
.all()
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_roster(
|
| 95 |
+
db: Session,
|
| 96 |
+
user_id: str,
|
| 97 |
+
page: int = 1,
|
| 98 |
+
per_page: int = 20,
|
| 99 |
+
search: str = "",
|
| 100 |
+
major: str = "",
|
| 101 |
+
status: str = "",
|
| 102 |
+
all_: bool = False,
|
| 103 |
+
) -> dict:
|
| 104 |
+
submissions = {
|
| 105 |
+
s.nrp: s
|
| 106 |
+
for s in db.query(Student).filter(Student.user_id == user_id).all()
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
entries = []
|
| 110 |
+
for nrp, name in _NRP_MAP.items():
|
| 111 |
+
if search and search.lower() not in nrp.lower() and search.lower() not in name.lower():
|
| 112 |
+
continue
|
| 113 |
+
sub = submissions.get(nrp)
|
| 114 |
+
entry_major = resolve_nrp_data(nrp)["major"]
|
| 115 |
+
if major and entry_major != major:
|
| 116 |
+
continue
|
| 117 |
+
submitted = sub is not None
|
| 118 |
+
if status == "submitted" and not submitted:
|
| 119 |
+
continue
|
| 120 |
+
if status == "pending" and submitted:
|
| 121 |
+
continue
|
| 122 |
+
entries.append({
|
| 123 |
+
"nrp": nrp,
|
| 124 |
+
"name": name,
|
| 125 |
+
"major": entry_major,
|
| 126 |
+
"submitted": submitted,
|
| 127 |
+
"photo_url": sub.photo_url if sub else None,
|
| 128 |
+
"hometown": sub.hometown if sub else None,
|
| 129 |
+
"hobbies": sub.hobbies if sub else None,
|
| 130 |
+
"first_impression": sub.first_impression if sub else None,
|
| 131 |
+
"submission_id": sub.id if sub else None,
|
| 132 |
+
"captured_at": sub.captured_at.isoformat() if sub and sub.captured_at else None,
|
| 133 |
+
"latitude": sub.latitude if sub else None,
|
| 134 |
+
"longitude": sub.longitude if sub else None,
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
entries.sort(key=lambda e: e["nrp"])
|
| 138 |
+
total = len(entries)
|
| 139 |
+
if all_:
|
| 140 |
+
return {
|
| 141 |
+
"entries": entries,
|
| 142 |
+
"total": total,
|
| 143 |
+
"submitted_count": sum(1 for e in entries if e["submitted"]),
|
| 144 |
+
}
|
| 145 |
+
total_pages = max(1, (total + per_page - 1) // per_page)
|
| 146 |
+
start = (page - 1) * per_page
|
| 147 |
+
paged = entries[start:start + per_page]
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"entries": paged,
|
| 151 |
+
"total": total,
|
| 152 |
+
"page": page,
|
| 153 |
+
"per_page": per_page,
|
| 154 |
+
"total_pages": total_pages,
|
| 155 |
+
"submitted_count": sum(1 for e in entries if e["submitted"]),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
_NRP_MAP = {
|
| 160 |
+
"5025251001": "Naura Rizky Ameira",
|
| 161 |
+
"5025251002": "Muhammad Faris Alfarrel",
|
| 162 |
+
"5025251004": "Ahmad Faruq Azzam",
|
| 163 |
+
"5025251005": "Ahmad Farras Favian Al Efasi",
|
| 164 |
+
"5025251006": "Farras Al Ghifari",
|
| 165 |
+
"5025251009": "Athar Rozy Rasyidan",
|
| 166 |
+
"5025251010": "Agile Octa Agrakha Handrian",
|
| 167 |
+
"5025251011": "Dzulfiqar Rafi'ussunnah",
|
| 168 |
+
"5025251012": "Khumaidy Syafiq El Maududy",
|
| 169 |
+
"5025251013": "Nisrina Zahralilla",
|
| 170 |
+
"5025251014": "Padhang Abiyu Fikri",
|
| 171 |
+
"5025251015": "Renato Kiran Arisandi",
|
| 172 |
+
"5025251016": "Keven John Gondowardojo",
|
| 173 |
+
"5025251017": "Wafi Fawwaz Sutisna",
|
| 174 |
+
"5025251018": "Farrel Marvellino Sugianto",
|
| 175 |
+
"5025251019": "Fakhrian Elanta",
|
| 176 |
+
"5025251020": "Firsto Al Kautsar Jagad Kurniaji",
|
| 177 |
+
"5025251021": "Yahdilil Haq Sarifuddin",
|
| 178 |
+
"5025251022": "Ahmad Radho Alfariz",
|
| 179 |
+
"5025251023": "Chairunnisa` Tsabitah",
|
| 180 |
+
"5025251024": "Rinaltra Nabasa Simanungkalit",
|
| 181 |
+
"5025251025": "Elba Galuh Hardiyanti",
|
| 182 |
+
"5025251027": "Hadryan Rizky Dimas Saputra",
|
| 183 |
+
"5025251028": "Julianda Caesar Prakoso",
|
| 184 |
+
"5025251031": "Najwan Sigit Cahya Buana",
|
| 185 |
+
"5025251032": "Nagita Aliya Sanopa",
|
| 186 |
+
"5025251033": "Jeremee Rafael Wynn",
|
| 187 |
+
"5025251034": "Ferdyan Dimas Satria",
|
| 188 |
+
"5025251035": "Aston Justin Holiwono",
|
| 189 |
+
"5025251036": "Darwisy Ahmad Alfayyadl",
|
| 190 |
+
"5025251037": "Hasan Mohammadi",
|
| 191 |
+
"5025251038": "Jovan Steve Antony",
|
| 192 |
+
"5025251039": "Benedictus Imanuel Wicaksono",
|
| 193 |
+
"5025251040": "Nurmaida Intan Permadani",
|
| 194 |
+
"5025251041": "Muhammad Adinata Parikesit",
|
| 195 |
+
"5025251042": "Yulius Restu Putranto",
|
| 196 |
+
"5025251043": "Tri Zuyyina Rohmah",
|
| 197 |
+
"5025251045": "Khalisya Zahra Putria Rahman",
|
| 198 |
+
"5025251046": "Muhammad Fairuz Ananta",
|
| 199 |
+
"5025251047": "Akhmad Fahmi",
|
| 200 |
+
"5025251048": "Rado Putra Yustisiawan",
|
| 201 |
+
"5025251049": "Husna 'Akifah",
|
| 202 |
+
"5025251050": "Muhammad Fajrul Hakam",
|
| 203 |
+
"5025251051": "Andra Safir Gemintang",
|
| 204 |
+
"5025251052": "Hilmy Fausta Pratama",
|
| 205 |
+
"5025251053": "M. Haziq Ridwan Parsa",
|
| 206 |
+
"5025251054": "Earlang Rangga Purwanto",
|
| 207 |
+
"5025251055": "Aga Nafta Filadelfiano",
|
| 208 |
+
"5025251056": "Sulthaan Daffaa'hadiansyach",
|
| 209 |
+
"5025251057": "Gabriella Putri Atmaja Adi",
|
| 210 |
+
"5025251058": "Shine Lee Romenzio Tarigan",
|
| 211 |
+
"5025251059": "Arsya Argananta",
|
| 212 |
+
"5025251060": "Sanchia Revana Koasasi",
|
| 213 |
+
"5025251061": "Bayu Setyo Nugroho",
|
| 214 |
+
"5025251062": "Aisyah Putri Diza",
|
| 215 |
+
"5025251063": "Lina Mushlihah",
|
| 216 |
+
"5025251064": "Mas Ayu Lana Afiah",
|
| 217 |
+
"5025251065": "Rida Izzati Azzahra",
|
| 218 |
+
"5025251066": "Nabil Khairie",
|
| 219 |
+
"5025251067": "Azka Fairus Syamsa",
|
| 220 |
+
"5025251068": "Satya Mahardika Sandyaditama",
|
| 221 |
+
"5025251069": "Evannio Michael Christeben Putra",
|
| 222 |
+
"5025251070": "Pandutama Putra Difira",
|
| 223 |
+
"5025251071": "Nasywa Zhafirah",
|
| 224 |
+
"5025251072": "Deborah Amalia Sheraviningrum",
|
| 225 |
+
"5025251073": "Ilham Husni Pratama",
|
| 226 |
+
"5025251075": "Muhammad Naufal Syahputra",
|
| 227 |
+
"5025251076": "Muhammad Faqih",
|
| 228 |
+
"5025251077": "Bastian Gerry Simangunsong",
|
| 229 |
+
"5025251078": "Muhammad Dida Pandawa",
|
| 230 |
+
"5025251079": "Isham Hawali Arijuddin",
|
| 231 |
+
"5025251080": "Kevin Jonathan Messakh",
|
| 232 |
+
"5025251081": "Luthfir Rizqy Fathullah Hanggi",
|
| 233 |
+
"5025251082": "M. Aqsha Syadindha Putra",
|
| 234 |
+
"5025251083": "Tubagus Muhamad Afif",
|
| 235 |
+
"5025251084": "Radhit Akriandra",
|
| 236 |
+
"5025251087": "Zakary Nareswara Tulus Sinudewangga Hatmadipura",
|
| 237 |
+
"5025251088": "I Komang Bagus Alvero Wisnawa",
|
| 238 |
+
"5025251089": "Nabila Zalfaa Putri Hamid",
|
| 239 |
+
"5025251090": "Nailla Chrysant Jelita",
|
| 240 |
+
"5025251091": "Mumtaz Hanumi Fayazida",
|
| 241 |
+
"5025251092": "Dhia Hiroyuki Prawira",
|
| 242 |
+
"5025251093": "Muhammad Hanif Musyafa",
|
| 243 |
+
"5025251094": "I Gusti Agung Wijnana Aryasa",
|
| 244 |
+
"5025251095": "Alessandro Almaz Filemon",
|
| 245 |
+
"5025251096": "Gabriel Mesly Managam Siahaan",
|
| 246 |
+
"5025251097": "Caroline Alverina",
|
| 247 |
+
"5025251098": "Wistara Banyu Kayana",
|
| 248 |
+
"5025251099": "Faris Rashid Azizi",
|
| 249 |
+
"5025251100": "Yudith Hafiz Rabbani",
|
| 250 |
+
"5025251101": "Ayudya Devina Azzahra",
|
| 251 |
+
"5025251102": "Muhammad Ludaka Firdaus",
|
| 252 |
+
"5025251103": "Rafifah Nabil Rahmadian",
|
| 253 |
+
"5025251104": "Amanda Putri Chaerunnisa",
|
| 254 |
+
"5025251105": "Gita Renada",
|
| 255 |
+
"5025251106": "Asher Yedijah Hoesono",
|
| 256 |
+
"5025251107": "Dimas Adiyaksa",
|
| 257 |
+
"5025251108": "Dimas Maulana Putra",
|
| 258 |
+
"5025251109": "Marveilleux Putra Mahasura",
|
| 259 |
+
"5025251110": "Muhammad Fauzta Putra Kavie",
|
| 260 |
+
"5025251111": "Ahmad Rafli Syarif Attallah",
|
| 261 |
+
"5025251112": "Faizaturrahmah Baity",
|
| 262 |
+
"5025251113": "Dewa Fitrah Fakhrusy Imron",
|
| 263 |
+
"5025251114": "Sheila Alvina Tsabita",
|
| 264 |
+
"5025251115": "Rayyan Aura Rahman",
|
| 265 |
+
"5025251116": "Moh. Zidan Ilmi Alwi",
|
| 266 |
+
"5025251117": "Faeyzar Ahnaf Musyarri",
|
| 267 |
+
"5025251118": "Anang Ardhiansyah",
|
| 268 |
+
"5025251120": "Alogo Hasiholan Napitupulu",
|
| 269 |
+
"5025251121": "Haidar Abiyyu At Taqy",
|
| 270 |
+
"5025251122": "Maida Aqillah Putri Nurandani",
|
| 271 |
+
"5025251124": "Muhammad Alhady Rizq",
|
| 272 |
+
"5025251125": "Muhammad Afzal Fulvian Handoni",
|
| 273 |
+
"5025251126": "Budiman Setiono",
|
| 274 |
+
"5025251129": "Muhammad Brahmana Priambudi",
|
| 275 |
+
"5025251130": "Aziz Alfarisi",
|
| 276 |
+
"5025251131": "Muhammad Azka Asyrafany",
|
| 277 |
+
"5025251132": "Kyla Rahma Maulida",
|
| 278 |
+
"5025251133": "Garda Putra Brahmantya",
|
| 279 |
+
"5025251134": "Althea Rahmania Fitri",
|
| 280 |
+
"5025251135": "Gadhiza Edgina Ikhwana Putri",
|
| 281 |
+
"5025251136": "Yudhistira Eka Pratama",
|
| 282 |
+
"5025251137": "Rayyan Aqsha Raditya",
|
| 283 |
+
"5025251138": "Farrel Satria Mukti",
|
| 284 |
+
"5025251139": "Gita Aulia",
|
| 285 |
+
"5025251140": "Tristan Athala Rizqullah Al Farisi",
|
| 286 |
+
"5025251141": "Muhammad Fahmi Ilmi",
|
| 287 |
+
"5025251142": "Rafi Atha Maulana",
|
| 288 |
+
"5025251143": "Ahmad Fakhrul Bawani",
|
| 289 |
+
"5025251144": "Muhammad Raihan Ar Royyan Daryanto",
|
| 290 |
+
"5025251145": "Raffa Atha Maulana",
|
| 291 |
+
"5025251146": "Febrian Ananda Tjahjono",
|
| 292 |
+
"5025251147": "Nicholaus Ardian Nugraha",
|
| 293 |
+
"5025251148": "Rahmavida Novita Setiani",
|
| 294 |
+
"5025251149": "Putu Pradipta Ananda",
|
| 295 |
+
"5025251150": "Made Joshua Ama Ede",
|
| 296 |
+
"5025251151": "Komang Mahatma Langendria",
|
| 297 |
+
"5025251152": "Dewa Ngakan Putu Sunyananda Triyanca",
|
| 298 |
+
"5025251153": "I Gede Made Adi Putra Adnyana",
|
| 299 |
+
"5025251154": "Boy Steven Benaya Aritonang",
|
| 300 |
+
"5025251155": "Wanhardo Jawak",
|
| 301 |
+
"5025251156": "Valian Athalla Syahputra",
|
| 302 |
+
"5025251158": "Aditya Lingga Mardika",
|
| 303 |
+
"5025251159": "Maulana Anugra Putra",
|
| 304 |
+
"5025251160": "Vania Aisha Rohmawati",
|
| 305 |
+
"5025251161": "Rizqi Arya Kuskhilbyano",
|
| 306 |
+
"5025251166": "Muhammad Kholid Zulfikar",
|
| 307 |
+
"5025251167": "Gabriela Asima Nainggolan",
|
| 308 |
+
"5025251168": "Lina Fatima Azzahra Badr",
|
| 309 |
+
"5025251169": "I Gusti Agung Candra Nugraha",
|
| 310 |
+
"5025251170": "Hussein Mohammad Mahsun",
|
| 311 |
+
"5025251171": "Daniel Pedrosaputra",
|
| 312 |
+
"5025251172": "Zainab Ammar Zahra",
|
| 313 |
+
"5025251174": "Marco Marcelino",
|
| 314 |
+
"5025251175": "Rafif Athalail Y",
|
| 315 |
+
"5025251176": "Kevinansyah Salviano Rachmadewa",
|
| 316 |
+
"5025251177": "Fathan Nurtamam Amry",
|
| 317 |
+
"5025251178": "Fazli Irham Ramadhan Abdillah",
|
| 318 |
+
"5025251179": "Muhammad Faiz Haq",
|
| 319 |
+
"5025251181": "Chaniel Daniello Junitona Tarigan",
|
| 320 |
+
"5025251182": "Yesenia Valencia Wibowo",
|
| 321 |
+
"5025251183": "Aditya Hariyadi Tjtujitno",
|
| 322 |
+
"5025251184": "Claresta Amelinda Hutauruk",
|
| 323 |
+
"5025251185": "Muhammad Irsyad Prihasto",
|
| 324 |
+
"5025251187": "Farrel Rizqi Pangestu",
|
| 325 |
+
"5025251188": "Novaldi Rayhan Asshiddiqi",
|
| 326 |
+
"5025251189": "Putu Dylan Pryana",
|
| 327 |
+
"5025251190": "Sayyid Faiz Al Izzuddin",
|
| 328 |
+
"5025251191": "Salsabila Hana Adniah",
|
| 329 |
+
"5025251193": "Raihan Naufal Ramadhan",
|
| 330 |
+
"5025251194": "I Nyoman Gede Anargya Sean Budhi Yasa",
|
| 331 |
+
"5025251195": "Dafa Dega Wijaya",
|
| 332 |
+
"5025251196": "I Dewa Gede Putra Susila",
|
| 333 |
+
"5025251198": "Bryan Darrick Pangedi",
|
| 334 |
+
"5025251199": "Muhammad Aqsan",
|
| 335 |
+
"5025251200": "Rafi Eka Pramudya",
|
| 336 |
+
"5025251201": "Fadlie Akbar Indrianto",
|
| 337 |
+
"5025251202": "Fawwas Razzan Sulfi Andreyawan",
|
| 338 |
+
"5025251203": "Muhammad Aqilah Arianto",
|
| 339 |
+
"5025251204": "Althof Rahmatullah",
|
| 340 |
+
"5025251205": "I Made Saskara Bawa",
|
| 341 |
+
"5025251206": "Rexa Matutu Harsaputra",
|
| 342 |
+
"5025251207": "Kezia Livina",
|
| 343 |
+
"5025251208": "Rafifah Rahmah Admayana",
|
| 344 |
+
"5025251209": "Marvel Timothy Noya",
|
| 345 |
+
"5025251210": "Fany Haikal Ahmad",
|
| 346 |
+
"5025251211": "Rizqi Ardiansyah Putra Pratama",
|
| 347 |
+
"5025251212": "Joel Angga Fransmartua Manalu",
|
| 348 |
+
"5025251213": "Ummi Kalsum Azzahra",
|
| 349 |
+
"5025251214": "Vijvika Nala Sanuratri",
|
| 350 |
+
"5025251215": "M Adhwa Athallah Tsani Anargya",
|
| 351 |
+
"5025251216": "Jessica Febiola",
|
| 352 |
+
"5025251217": "Syabil Zihni",
|
| 353 |
+
"5025251218": "Mushallina Dzikri Rozana",
|
| 354 |
+
"5025251219": "Farrel Fahrezi Rizvanala",
|
| 355 |
+
"5025251221": "Rachmat Ahzadel",
|
| 356 |
+
"5025251222": "I Dewa Nyoman Acarya Wibawantra",
|
| 357 |
+
"5025251223": "Sayyidah Fatimah Azzahrah Rakhmatullah",
|
| 358 |
+
"5025251225": "Fayyadh Ahmad Zuhri",
|
| 359 |
+
"5025251226": "Grandira Haidee Alexandro Letik",
|
| 360 |
+
"5025251227": "Khen Patra Yabes Sianipar",
|
| 361 |
+
"5025251229": "Muhammad Raffy Adika Putra Riyanto",
|
| 362 |
+
"5025251230": "Naila Hikaru",
|
| 363 |
+
"5025251231": "Nichola Matthew Hutabarat",
|
| 364 |
+
"5025251232": "Zidni Ammar Zadi",
|
| 365 |
+
"5025251233": "I Made Bagus Naratama Karangputra",
|
| 366 |
+
"5025251234": "Ahmad Fateh Eydil Faiq",
|
| 367 |
+
"5025251235": "Dhanishara Zaschya Putri Syamsudin",
|
| 368 |
+
"5025251236": "Gede Nararya Vatsa",
|
| 369 |
+
"5025251237": "Nur Handiena Deswinda Jefrimananda",
|
| 370 |
+
"5025251238": "Istaqim Makmun",
|
| 371 |
+
"5025251239": "Muhammad Faza Ismail",
|
| 372 |
+
"5025251240": "Medina Kusuma Prianda",
|
| 373 |
+
"5025251241": "Glenn Lucky Tangke Payung",
|
| 374 |
+
"5025251242": "Irsyad Ansyari Hakim",
|
| 375 |
+
"5025251243": "Irsyad Akbar",
|
| 376 |
+
"5025251245": "Hasna Nabila Hanim",
|
| 377 |
+
"5025251246": "Hamizan Rifqi Afandi",
|
| 378 |
+
"5025251247": "Gede Panji Dana Putra Ricedes",
|
| 379 |
+
"5025251248": "Maulana Bagas Rizqi Pratama",
|
| 380 |
+
"5025251249": "Enver Alif Wirawan",
|
| 381 |
+
"5025251251": "Mochammad Raja Defana",
|
| 382 |
+
"5025251252": "Matthew Adrian Putra",
|
| 383 |
+
"5025251253": "Darryl Ardan Wicaksono",
|
| 384 |
+
"5025251254": "Salsabila Shafadelarosa",
|
| 385 |
+
"5025251255": "Aghazy Setyo Nugroho",
|
| 386 |
+
"5025251257": "Rozan Hakim Abyandhono",
|
| 387 |
+
"5025251258": "Naila Sa'ada Cahyani",
|
| 388 |
+
"5025251259": "A. Deraya Meuthia Toja",
|
| 389 |
+
"5025251260": "Aqilah Ibrahim",
|
| 390 |
+
"5025251262": "Muhammad Rayza Buftiem",
|
| 391 |
+
"5025251263": "Raihan Ahmad Farraszaki",
|
| 392 |
+
"5025251264": "Ronald Ruben Sitopu",
|
| 393 |
+
"5025251265": "Affan Rafi Habibie",
|
| 394 |
+
"5025251268": "Safiya Nadira Az-Zahra",
|
| 395 |
+
"5025251269": "Daffa Arya Satyatma",
|
| 396 |
+
"5025251270": "Muhamad Alfareza Hariesa Pratama",
|
| 397 |
+
"5025251272": "Raka Rajendra Dipo Alam",
|
| 398 |
+
"5025251273": "Cokorda Bagus Laksmana Iswara",
|
| 399 |
+
"5025251274": "Hafuza Riffat",
|
| 400 |
+
"5053251001": "Rokhmatul Ilma Khanifa",
|
| 401 |
+
"5053251002": "Selly Aisyah",
|
| 402 |
+
"5053251003": "Aurelia Pradnyaswari Sanjaya Erawan",
|
| 403 |
+
"5053251004": "M. Khauzaky Amkanaky",
|
| 404 |
+
"5053251005": "Dheva Alvian Alfarizzy Excellent",
|
| 405 |
+
"5053251006": "Aurelia Nurdiansyah Putri",
|
| 406 |
+
"5053251007": "Abdul Ghofur Luqman Salim",
|
| 407 |
+
"5053251008": "Sulthan Zakinun Nasywa",
|
| 408 |
+
"5053251009": "Muchammad Naufal Aziz",
|
| 409 |
+
"5053251011": "Achmad Mirza Arzheta Rahman",
|
| 410 |
+
"5053251012": "Hilmi Taqiyuddin Haq",
|
| 411 |
+
"5053251013": "Hilmi Fauzi Adha Simamora",
|
| 412 |
+
"5053251014": "Muhammad Rafi Riansjah",
|
| 413 |
+
"5053251015": "Hafidz Nur Ikhsan Isnaeni",
|
| 414 |
+
"5053251017": "Daffa Randika",
|
| 415 |
+
"5053251018": "Mhd. Ravy Enstein Wahieda Elmunif",
|
| 416 |
+
"5053251019": "Muhammad Nafis Al Khalifi",
|
| 417 |
+
"5053251020": "Yuwand Arteta Hydri Wahyu Putra",
|
| 418 |
+
"5053251021": "Ahmad Zaidaan",
|
| 419 |
+
"5053251022": "Muhammad Hanif Nasrullah",
|
| 420 |
+
"5053251023": "Afrel Zharif Muflih",
|
| 421 |
+
"5053251024": "Nathan Alden",
|
| 422 |
+
"5053251025": "Muhammad Fakhry Ziyad Dhiyaulhaq",
|
| 423 |
+
"5053251026": "Safia Rashida Raya",
|
| 424 |
+
"5053251027": "Deara Briliana Putri",
|
| 425 |
+
"5053251028": "Yeremia Gunawan",
|
| 426 |
+
"5053251029": "Alvian Faza Wafda Reonaldi",
|
| 427 |
+
"5053251030": "La Ode Muhammad Ghofaruddin S",
|
| 428 |
+
"5053251031": "Muhammad Hisyam Nurdy",
|
| 429 |
+
"5053251032": "Daniel Dhaniswara",
|
| 430 |
+
"5053251033": "Vincent Anindito Priyamboddo",
|
| 431 |
+
"5053251034": "Nabila Ainiya Rahman",
|
| 432 |
+
"5053251035": "Azendra Kenar Arviant",
|
| 433 |
+
"5053251036": "Farrel Aryasatya Raharjo",
|
| 434 |
+
"5053251038": "Marsya Safina Maulidiyah",
|
| 435 |
+
"5053251039": "Devin Faza Raditya",
|
| 436 |
+
"5053251040": "Muhammad Zaydan Anugrah Pratama",
|
| 437 |
+
"5053251041": "Moch Siril Wafa Zidane Feliano",
|
| 438 |
+
"5053251042": "Ahmad Balya Malkan",
|
| 439 |
+
"5053251043": "Muhammad Farel Al Farisi",
|
| 440 |
+
"5053251044": "Fauzan Hasyim",
|
| 441 |
+
"5053251045": "Dzakwan Ghonim Fath'han Mubina",
|
| 442 |
+
"5053251046": "Reifan Al-Fattii Cahyadewa",
|
| 443 |
+
"5053251047": "Achmad Raffi Darmawan",
|
| 444 |
+
"5053251048": "Rilo Zidane Afrianta Tambunan",
|
| 445 |
+
"5053251049": "Matahari Gracio Sinaga",
|
| 446 |
+
"5053251050": "Reza",
|
| 447 |
+
"5054251001": "Benedictus Ryu Gunawan",
|
| 448 |
+
"5054251002": "Aziz Rahmad Arifin",
|
| 449 |
+
"5054251003": "Firdaus Mangkona",
|
| 450 |
+
"5054251004": "Muhammad Azka Ananta Khairon",
|
| 451 |
+
"5054251006": "Muhammad Fasya Atthaya Rosyada",
|
| 452 |
+
"5054251008": "Hilal Tsabitul Azmi Arba'i",
|
| 453 |
+
"5054251009": "Pranaja Adyatma Budiman",
|
| 454 |
+
"5054251010": "Rakha Makarim",
|
| 455 |
+
"5054251011": "Maria Putu Evelyne Corena Puryatma",
|
| 456 |
+
"5054251013": "Evina Fitriyani",
|
| 457 |
+
"5054251014": "Muhammad Rafie Safaraz Barus",
|
| 458 |
+
"5054251015": "Fahmi Alfayadh",
|
| 459 |
+
"5054251016": "Vincent Valentino",
|
| 460 |
+
"5054251017": "Rafa Rizki Aira Swala",
|
| 461 |
+
"5054251018": "Dinnetza Araafya Yudetti",
|
| 462 |
+
"5054251019": "Rasya Arya Ramadhan",
|
| 463 |
+
"5054251020": "Sandi Suryo Nugroho",
|
| 464 |
+
"5054251021": "Muhammad Fardan Hafidz",
|
| 465 |
+
"5054251022": "Daffa Aufa Afif",
|
| 466 |
+
"5054251023": "Zico Diego Rio Ramadhonny",
|
| 467 |
+
"5054251024": "Muhammad Irzam Hafis Fabiansyah",
|
| 468 |
+
"5054251025": "Rayyan Binar Ramadhan",
|
| 469 |
+
"5054251026": "Jonathan Joyo Wibowo",
|
| 470 |
+
"5054251027": "Faiz Farrosian Karim",
|
| 471 |
+
"5054251028": "Malfino Muhammad Willianz",
|
| 472 |
+
"5054251029": "Intifada Afkar Lazain Muhammad",
|
| 473 |
+
"5054251030": "Raihan Nurhakim",
|
| 474 |
+
"5054251032": "Rasya Gonawi",
|
| 475 |
+
"5054251033": "Levina Zahrathul Huda",
|
| 476 |
+
"5054251036": "Prima Surya Nusantara",
|
| 477 |
+
"5054251037": "Lioneil Diyoel Trystan Tikupadang",
|
| 478 |
+
"5054251039": "Muhammad Dzaky Haidar",
|
| 479 |
+
"5054251035": "Fauzan Tamma Harish Kunfaza",
|
| 480 |
+
"5054251041": "Maulana Rhys Pradana",
|
| 481 |
+
"5054251042": "Muhammad Farid Wijdan",
|
| 482 |
+
"5054251044": "Ahmad Zaki Fauzan Nabil",
|
| 483 |
+
"5054251045": "Mahardika Indra Pratama Ilyasa",
|
| 484 |
+
"5054251046": "Suhail Ainur Rofiq",
|
| 485 |
+
"5054251047": "A. Toriq Azhar",
|
| 486 |
+
"5054251048": "Sulaiman Faiz Tsaqib",
|
| 487 |
+
"5054251050": "Kadek Nathania Gavrila Astika",
|
| 488 |
+
"5054251051": "Rayyan Muhtar Ali",
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def find_name_from_nrp(nrp: str) -> str:
|
| 493 |
+
return _NRP_MAP.get(nrp, "Unknown")
|
app/config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
app_name: str = "Bukang API"
|
| 6 |
+
debug: bool = False
|
| 7 |
+
database_url: str = "sqlite:///./bukang.db"
|
| 8 |
+
secret_key: str = "change-me-in-production"
|
| 9 |
+
algorithm: str = "HS256"
|
| 10 |
+
access_token_expire_minutes: int = 60
|
| 11 |
+
cors_origins: str = "*"
|
| 12 |
+
hf_storage_repo: str = ""
|
| 13 |
+
hf_token: str = ""
|
| 14 |
+
model_config = {"env_file": "../.env"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
settings = Settings()
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def setup_logging() -> None:
|
| 5 |
+
logging.basicConfig(
|
| 6 |
+
level=logging.INFO,
|
| 7 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 8 |
+
)
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/base.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Base(DeclarativeBase):
|
| 5 |
+
pass
|
app/db/session.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker
|
| 3 |
+
|
| 4 |
+
from app.config import settings
|
| 5 |
+
|
| 6 |
+
engine = create_engine(
|
| 7 |
+
settings.database_url,
|
| 8 |
+
pool_pre_ping=True,
|
| 9 |
+
pool_recycle=300,
|
| 10 |
+
)
|
| 11 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_db():
|
| 15 |
+
db = SessionLocal()
|
| 16 |
+
try:
|
| 17 |
+
yield db
|
| 18 |
+
finally:
|
| 19 |
+
db.close()
|
app/domain/__init__.py
ADDED
|
File without changes
|
app/domain/entities/__init__.py
ADDED
|
File without changes
|
app/domain/entities/user.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class User:
|
| 7 |
+
id: str
|
| 8 |
+
username: str
|
| 9 |
+
hashed_password: str
|
| 10 |
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
def create(username: str, hashed_password: str, user_id: str) -> "User":
|
| 14 |
+
return User(
|
| 15 |
+
id=user_id,
|
| 16 |
+
username=username,
|
| 17 |
+
hashed_password=hashed_password,
|
| 18 |
+
)
|
app/domain/repositories/__init__.py
ADDED
|
File without changes
|
app/domain/repositories/user_repository.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
|
| 3 |
+
from app.domain.entities.user import User
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class UserRepository(ABC):
|
| 7 |
+
@abstractmethod
|
| 8 |
+
def find_by_id(self, user_id: str) -> User | None:
|
| 9 |
+
...
|
| 10 |
+
|
| 11 |
+
@abstractmethod
|
| 12 |
+
def find_by_username(self, username: str) -> User | None:
|
| 13 |
+
...
|
| 14 |
+
|
| 15 |
+
@abstractmethod
|
| 16 |
+
def save(self, user: User) -> User:
|
| 17 |
+
...
|
app/infrastructure/__init__.py
ADDED
|
File without changes
|
app/infrastructure/persistence/__init__.py
ADDED
|
File without changes
|
app/infrastructure/persistence/models/__init__.py
ADDED
|
File without changes
|
app/infrastructure/persistence/models/student.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, Text, Float, ForeignKey
|
| 5 |
+
|
| 6 |
+
from app.db.base import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class StudentModel(Base):
|
| 10 |
+
__tablename__ = "students"
|
| 11 |
+
|
| 12 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 13 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 14 |
+
nrp = Column(String, nullable=False, index=True)
|
| 15 |
+
name = Column(String, nullable=False)
|
| 16 |
+
hometown = Column(String, nullable=True)
|
| 17 |
+
major = Column(String, nullable=False)
|
| 18 |
+
photo_url = Column(Text, nullable=True)
|
| 19 |
+
hobbies = Column(Text, nullable=True)
|
| 20 |
+
first_impression = Column(Text, nullable=True)
|
| 21 |
+
longitude = Column(Float, nullable=True)
|
| 22 |
+
latitude = Column(Float, nullable=True)
|
| 23 |
+
captured_at = Column(DateTime, nullable=True)
|
| 24 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 25 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
app/infrastructure/persistence/models/user.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import Column, String, DateTime
|
| 5 |
+
|
| 6 |
+
from app.db.base import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class UserModel(Base):
|
| 10 |
+
__tablename__ = "users"
|
| 11 |
+
|
| 12 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 13 |
+
username = Column(String, unique=True, nullable=False, index=True)
|
| 14 |
+
hashed_password = Column(String, nullable=False)
|
| 15 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
app/infrastructure/persistence/repositories/__init__.py
ADDED
|
File without changes
|
app/infrastructure/persistence/repositories/user_repository.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.domain.entities.user import User
|
| 5 |
+
from app.domain.repositories.user_repository import UserRepository
|
| 6 |
+
from app.infrastructure.persistence.models.user import UserModel
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SQLAlchemyUserRepository(UserRepository):
|
| 10 |
+
def __init__(self, db: Session):
|
| 11 |
+
self._db = db
|
| 12 |
+
|
| 13 |
+
def find_by_id(self, user_id: str) -> User | None:
|
| 14 |
+
model = self._db.query(UserModel).filter(UserModel.id == user_id).first()
|
| 15 |
+
if model is None:
|
| 16 |
+
return None
|
| 17 |
+
return self._to_entity(model)
|
| 18 |
+
|
| 19 |
+
def find_by_username(self, username: str) -> User | None:
|
| 20 |
+
model = self._db.query(UserModel).filter(UserModel.username == username).first()
|
| 21 |
+
if model is None:
|
| 22 |
+
return None
|
| 23 |
+
return self._to_entity(model)
|
| 24 |
+
|
| 25 |
+
def save(self, user: User) -> User:
|
| 26 |
+
model = UserModel(
|
| 27 |
+
id=user.id or str(uuid.uuid4()),
|
| 28 |
+
username=user.username,
|
| 29 |
+
hashed_password=user.hashed_password,
|
| 30 |
+
)
|
| 31 |
+
self._db.add(model)
|
| 32 |
+
self._db.commit()
|
| 33 |
+
self._db.refresh(model)
|
| 34 |
+
return self._to_entity(model)
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
def _to_entity(model: UserModel) -> User:
|
| 38 |
+
return User(
|
| 39 |
+
id=model.id,
|
| 40 |
+
username=model.username,
|
| 41 |
+
hashed_password=model.hashed_password,
|
| 42 |
+
created_at=model.created_at,
|
| 43 |
+
)
|
app/infrastructure/security/__init__.py
ADDED
|
File without changes
|
app/infrastructure/security/jwt.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from app.application.interfaces.jwt import JWTService
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class JoseJWTService(JWTService):
|
| 10 |
+
def create_access_token(self, data: dict) -> str:
|
| 11 |
+
to_encode = data.copy()
|
| 12 |
+
expire = datetime.now(timezone.utc) + timedelta(
|
| 13 |
+
minutes=settings.access_token_expire_minutes
|
| 14 |
+
)
|
| 15 |
+
to_encode.update({"exp": expire})
|
| 16 |
+
return jwt.encode(
|
| 17 |
+
to_encode, settings.secret_key, algorithm=settings.algorithm
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
def decode_token(self, token: str) -> dict:
|
| 21 |
+
try:
|
| 22 |
+
return jwt.decode(
|
| 23 |
+
token, settings.secret_key, algorithms=[settings.algorithm]
|
| 24 |
+
)
|
| 25 |
+
except JWTError:
|
| 26 |
+
raise ValueError("Invalid token")
|
app/infrastructure/security/password.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt
|
| 2 |
+
|
| 3 |
+
from app.application.interfaces.password import PasswordService
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class BcryptPasswordService(PasswordService):
|
| 7 |
+
def hash(self, password: str) -> str:
|
| 8 |
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
| 9 |
+
|
| 10 |
+
def verify(self, plain: str, hashed: str) -> bool:
|
| 11 |
+
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
app/infrastructure/storage/__init__.py
ADDED
|
File without changes
|
app/infrastructure/storage/hf_storage.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from huggingface_hub import batch_bucket_files
|
| 5 |
+
|
| 6 |
+
from app.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class StorageService:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.bucket_name = settings.hf_storage_repo
|
| 12 |
+
self.token = settings.hf_token
|
| 13 |
+
self._hf_enabled = bool(self.bucket_name and self.token)
|
| 14 |
+
self._upload_dir = "uploads"
|
| 15 |
+
|
| 16 |
+
def upload_photo(self, file_content: bytes, filename: str) -> str:
|
| 17 |
+
if self._hf_enabled:
|
| 18 |
+
return self._upload_to_hf(file_content, filename)
|
| 19 |
+
return self._upload_local(file_content, filename)
|
| 20 |
+
|
| 21 |
+
def _upload_to_hf(self, file_content: bytes, filename: str) -> str:
|
| 22 |
+
ext = Path(filename).suffix or ".jpg"
|
| 23 |
+
unique_name = f"{uuid.uuid4().hex}{ext}"
|
| 24 |
+
path_in_bucket = f"uploads/{unique_name}"
|
| 25 |
+
|
| 26 |
+
batch_bucket_files(
|
| 27 |
+
self.bucket_name,
|
| 28 |
+
add=[(file_content, path_in_bucket)],
|
| 29 |
+
token=self.token,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
return f"https://huggingface.co/buckets/{self.bucket_name}/{path_in_bucket}"
|
| 33 |
+
|
| 34 |
+
def _upload_local(self, file_content: bytes, filename: str) -> str:
|
| 35 |
+
import os
|
| 36 |
+
|
| 37 |
+
os.makedirs(self._upload_dir, exist_ok=True)
|
| 38 |
+
ext = Path(filename).suffix or ".jpg"
|
| 39 |
+
unique_name = f"{uuid.uuid4().hex}{ext}"
|
| 40 |
+
path = os.path.join(self._upload_dir, unique_name)
|
| 41 |
+
with open(path, "wb") as f:
|
| 42 |
+
f.write(file_content)
|
| 43 |
+
return f"/uploads/{unique_name}"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
storage_service = StorageService()
|
app/main.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.core.logging import setup_logging
|
| 11 |
+
from app.db.base import Base
|
| 12 |
+
from app.db.session import engine
|
| 13 |
+
from app.presentation.routers import auth, students
|
| 14 |
+
from app.presentation.schemas.response import error, success
|
| 15 |
+
|
| 16 |
+
tags_metadata = [
|
| 17 |
+
{
|
| 18 |
+
"name": "health",
|
| 19 |
+
"description": "Health check endpoint",
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"name": "auth",
|
| 23 |
+
"description": "User registration and authentication",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "students",
|
| 27 |
+
"description": "Student CRUD operations",
|
| 28 |
+
},
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def lifespan(app: FastAPI):
|
| 34 |
+
setup_logging()
|
| 35 |
+
Base.metadata.create_all(bind=engine)
|
| 36 |
+
yield
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
app = FastAPI(
|
| 40 |
+
title=settings.app_name,
|
| 41 |
+
description="Student Profile Collection API — Kumpulkan Profil Mahasiswa dalam satu tempat",
|
| 42 |
+
version="1.0.0",
|
| 43 |
+
lifespan=lifespan,
|
| 44 |
+
openapi_tags=tags_metadata,
|
| 45 |
+
swagger_ui_parameters={"persistAuthorization": True},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
origins = (
|
| 49 |
+
settings.cors_origins.split(",")
|
| 50 |
+
if settings.cors_origins != "*"
|
| 51 |
+
else ["*"]
|
| 52 |
+
)
|
| 53 |
+
app.add_middleware(
|
| 54 |
+
CORSMiddleware,
|
| 55 |
+
allow_origins=origins,
|
| 56 |
+
allow_credentials=True,
|
| 57 |
+
allow_methods=["*"],
|
| 58 |
+
allow_headers=["*"],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.exception_handler(HTTPException)
|
| 63 |
+
def http_exception_handler(_: Request, exc: HTTPException):
|
| 64 |
+
return JSONResponse(
|
| 65 |
+
status_code=exc.status_code,
|
| 66 |
+
content=error(exc.detail, exc.status_code),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@app.exception_handler(Exception)
|
| 71 |
+
def generic_exception_handler(_: Request, exc: Exception):
|
| 72 |
+
return JSONResponse(
|
| 73 |
+
status_code=500,
|
| 74 |
+
content=error("Internal server error", 500),
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
os.makedirs("uploads", exist_ok=True)
|
| 79 |
+
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
| 80 |
+
|
| 81 |
+
app.include_router(auth.router)
|
| 82 |
+
app.include_router(students.router)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@app.get("/")
|
| 86 |
+
def root():
|
| 87 |
+
return JSONResponse(
|
| 88 |
+
content={"message": "Bukang API is running", "docs": "/docs"}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@app.get(
|
| 93 |
+
"/api/health",
|
| 94 |
+
tags=["health"],
|
| 95 |
+
summary="Check API health",
|
| 96 |
+
responses={
|
| 97 |
+
200: {"description": "API is healthy"},
|
| 98 |
+
},
|
| 99 |
+
)
|
| 100 |
+
def health():
|
| 101 |
+
return success({"status": "ok"})
|
app/presentation/__init__.py
ADDED
|
File without changes
|
app/presentation/dependencies.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
|
| 5 |
+
from app.db.session import get_db
|
| 6 |
+
from app.domain.repositories.user_repository import UserRepository
|
| 7 |
+
from app.infrastructure.persistence.repositories.user_repository import (
|
| 8 |
+
SQLAlchemyUserRepository,
|
| 9 |
+
)
|
| 10 |
+
from app.infrastructure.security.jwt import JoseJWTService
|
| 11 |
+
from app.application.use_cases.auth_use_case import AuthUseCase
|
| 12 |
+
|
| 13 |
+
oauth2_scheme = HTTPBearer(auto_error=False)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_user_repository(db: Session = Depends(get_db)) -> UserRepository:
|
| 17 |
+
return SQLAlchemyUserRepository(db)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_auth_use_case(
|
| 21 |
+
user_repo: UserRepository = Depends(get_user_repository),
|
| 22 |
+
) -> AuthUseCase:
|
| 23 |
+
from app.infrastructure.security.password import BcryptPasswordService
|
| 24 |
+
|
| 25 |
+
return AuthUseCase(
|
| 26 |
+
user_repository=user_repo,
|
| 27 |
+
password_service=BcryptPasswordService(),
|
| 28 |
+
jwt_service=JoseJWTService(),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_current_user(
|
| 33 |
+
cred: HTTPAuthorizationCredentials | None = Depends(oauth2_scheme),
|
| 34 |
+
db: Session = Depends(get_db),
|
| 35 |
+
) -> str:
|
| 36 |
+
credentials_exception = HTTPException(
|
| 37 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 38 |
+
detail="Not authenticated",
|
| 39 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 40 |
+
)
|
| 41 |
+
if cred is None:
|
| 42 |
+
raise credentials_exception
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
jwt_service = JoseJWTService()
|
| 46 |
+
payload = jwt_service.decode_token(cred.credentials)
|
| 47 |
+
user_id: str | None = payload.get("sub")
|
| 48 |
+
if user_id is None:
|
| 49 |
+
raise credentials_exception
|
| 50 |
+
except ValueError:
|
| 51 |
+
raise credentials_exception
|
| 52 |
+
|
| 53 |
+
repo = SQLAlchemyUserRepository(db)
|
| 54 |
+
user = repo.find_by_id(user_id)
|
| 55 |
+
if user is None:
|
| 56 |
+
raise credentials_exception
|
| 57 |
+
return user_id
|
app/presentation/routers/__init__.py
ADDED
|
File without changes
|
app/presentation/routers/auth.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
|
| 3 |
+
from app.application.use_cases.auth_use_case import AuthUseCase, RegisterRequest, LoginRequest
|
| 4 |
+
from app.presentation.dependencies import get_auth_use_case
|
| 5 |
+
from app.presentation.schemas.auth import RegisterRequest as RegisterSchema, LoginRequest as LoginSchema
|
| 6 |
+
from app.presentation.schemas.response import (
|
| 7 |
+
TokenResponseSchema,
|
| 8 |
+
UserResponseSchema,
|
| 9 |
+
common_error,
|
| 10 |
+
success,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/register",
|
| 18 |
+
summary="Register a new user",
|
| 19 |
+
responses={
|
| 20 |
+
201: {
|
| 21 |
+
"model": UserResponseSchema,
|
| 22 |
+
"description": "User registered successfully",
|
| 23 |
+
},
|
| 24 |
+
400: {"description": "Username already taken"},
|
| 25 |
+
**common_error,
|
| 26 |
+
},
|
| 27 |
+
)
|
| 28 |
+
def register(
|
| 29 |
+
req: RegisterSchema,
|
| 30 |
+
auth_use_case: AuthUseCase = Depends(get_auth_use_case),
|
| 31 |
+
):
|
| 32 |
+
try:
|
| 33 |
+
result = auth_use_case.register(
|
| 34 |
+
RegisterRequest(username=req.username, password=req.password)
|
| 35 |
+
)
|
| 36 |
+
return success({"id": result.id, "username": result.username})
|
| 37 |
+
except ValueError as e:
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.post(
|
| 44 |
+
"/login",
|
| 45 |
+
summary="Login and get access token",
|
| 46 |
+
responses={
|
| 47 |
+
**common_error,
|
| 48 |
+
200: {
|
| 49 |
+
"model": TokenResponseSchema,
|
| 50 |
+
"description": "Login successful",
|
| 51 |
+
},
|
| 52 |
+
401: {"description": "Invalid username or password"},
|
| 53 |
+
},
|
| 54 |
+
)
|
| 55 |
+
def login(
|
| 56 |
+
req: LoginSchema,
|
| 57 |
+
auth_use_case: AuthUseCase = Depends(get_auth_use_case),
|
| 58 |
+
):
|
| 59 |
+
try:
|
| 60 |
+
result = auth_use_case.login(
|
| 61 |
+
LoginRequest(username=req.username, password=req.password)
|
| 62 |
+
)
|
| 63 |
+
return success(
|
| 64 |
+
{"access_token": result.access_token, "token_type": "bearer"}
|
| 65 |
+
)
|
| 66 |
+
except ValueError as e:
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)
|
| 69 |
+
)
|
app/presentation/routers/students.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status, Path
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.db.session import get_db
|
| 5 |
+
from app.presentation.dependencies import get_current_user
|
| 6 |
+
from app.presentation.schemas.response import (
|
| 7 |
+
StudentResponseSchema,
|
| 8 |
+
common_error,
|
| 9 |
+
success,
|
| 10 |
+
)
|
| 11 |
+
from app.presentation.schemas.student import (
|
| 12 |
+
SubmissionRequest,
|
| 13 |
+
)
|
| 14 |
+
from app.application.use_cases import student_use_case
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/students", tags=["students"])
|
| 17 |
+
|
| 18 |
+
# --- Public endpoints (no auth) ---
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get(
|
| 22 |
+
"/nrp/{nrp}",
|
| 23 |
+
summary="Resolve NRP to name & major (public)",
|
| 24 |
+
responses={
|
| 25 |
+
200: {"description": "NRP data resolved"},
|
| 26 |
+
404: {"description": "NRP not found"},
|
| 27 |
+
},
|
| 28 |
+
)
|
| 29 |
+
def resolve_nrp(nrp: str, db: Session = Depends(get_db)):
|
| 30 |
+
data = student_use_case.resolve_nrp_data(nrp, db)
|
| 31 |
+
if data["name"] == "Unknown":
|
| 32 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NRP not found")
|
| 33 |
+
return success(data)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post(
|
| 37 |
+
"/upload-photo",
|
| 38 |
+
summary="Upload photo (public)",
|
| 39 |
+
responses={
|
| 40 |
+
200: {"description": "Photo uploaded"},
|
| 41 |
+
},
|
| 42 |
+
)
|
| 43 |
+
def upload_photo(file: UploadFile):
|
| 44 |
+
url = student_use_case.save_photo(file)
|
| 45 |
+
return success({"photo_url": url})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# --- Protected endpoints (auth required) ---
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.post(
|
| 52 |
+
"/submissions",
|
| 53 |
+
status_code=201,
|
| 54 |
+
summary="Submit complete student profile",
|
| 55 |
+
responses={
|
| 56 |
+
201: {"model": StudentResponseSchema, "description": "Submission created"},
|
| 57 |
+
400: {"description": "Invalid data"},
|
| 58 |
+
},
|
| 59 |
+
)
|
| 60 |
+
def submit_profile(
|
| 61 |
+
data: SubmissionRequest,
|
| 62 |
+
db: Session = Depends(get_db),
|
| 63 |
+
user_id: str = Depends(get_current_user),
|
| 64 |
+
):
|
| 65 |
+
student = student_use_case.create_submission(db, user_id, data)
|
| 66 |
+
return success(student)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.get(
|
| 70 |
+
"",
|
| 71 |
+
summary="List own submissions or search by NRP",
|
| 72 |
+
responses={
|
| 73 |
+
**common_error,
|
| 74 |
+
200: {
|
| 75 |
+
"model": StudentResponseSchema,
|
| 76 |
+
"description": "List of submissions",
|
| 77 |
+
},
|
| 78 |
+
},
|
| 79 |
+
)
|
| 80 |
+
def list_students(
|
| 81 |
+
nrp: str | None = Query(
|
| 82 |
+
None, description="Search own submissions by NRP (partial match)"
|
| 83 |
+
),
|
| 84 |
+
db: Session = Depends(get_db),
|
| 85 |
+
user_id: str = Depends(get_current_user),
|
| 86 |
+
):
|
| 87 |
+
if nrp:
|
| 88 |
+
return success(student_use_case.search_students_by_nrp(db, user_id, nrp))
|
| 89 |
+
return success(student_use_case.get_students(db, user_id))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.get(
|
| 93 |
+
"/roster",
|
| 94 |
+
summary="Get full roster (NRP map + submission status) with pagination",
|
| 95 |
+
)
|
| 96 |
+
def get_roster(
|
| 97 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 98 |
+
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
| 99 |
+
search: str = Query("", description="Search by NRP or name"),
|
| 100 |
+
major: str = Query("", description="Filter by major (exact match)"),
|
| 101 |
+
status: str = Query("", description='Filter by submission status: "submitted", "pending", or empty for all'),
|
| 102 |
+
all: bool = Query(False, description="Return all matching entries without pagination"),
|
| 103 |
+
db: Session = Depends(get_db),
|
| 104 |
+
user_id: str = Depends(get_current_user),
|
| 105 |
+
):
|
| 106 |
+
data = student_use_case.get_roster(db, user_id, page, per_page, search, major, status, all_=all)
|
| 107 |
+
return success(data)
|
app/presentation/schemas/__init__.py
ADDED
|
File without changes
|
app/presentation/schemas/auth.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class TokenResponse(BaseModel):
|
| 5 |
+
access_token: str
|
| 6 |
+
token_type: str = "bearer"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class LoginRequest(BaseModel):
|
| 10 |
+
username: str
|
| 11 |
+
password: str
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RegisterRequest(BaseModel):
|
| 15 |
+
username: str
|
| 16 |
+
password: str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class UserResponse(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
username: str
|
app/presentation/schemas/response.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
from app.presentation.schemas.auth import TokenResponse, UserResponse
|
| 7 |
+
from app.presentation.schemas.student import StudentResponse
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _request_id() -> str:
|
| 11 |
+
return "req_" + uuid.uuid4().hex[:8]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _timestamp() -> str:
|
| 15 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def success(data):
|
| 19 |
+
return {
|
| 20 |
+
"success": True,
|
| 21 |
+
"data": data,
|
| 22 |
+
"meta": {"request_id": _request_id(), "timestamp": _timestamp()},
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def error(detail: str, status_code: int = 400):
|
| 27 |
+
return {
|
| 28 |
+
"success": False,
|
| 29 |
+
"data": {"detail": detail, "status_code": status_code},
|
| 30 |
+
"meta": {"request_id": _request_id(), "timestamp": _timestamp()},
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# --- OpenAPI documentation models ---
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Meta(BaseModel):
|
| 38 |
+
request_id: str
|
| 39 |
+
timestamp: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class StudentResponseSchema(BaseModel):
|
| 43 |
+
success: bool = True
|
| 44 |
+
data: StudentResponse
|
| 45 |
+
meta: Meta
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class StudentListResponseSchema(BaseModel):
|
| 49 |
+
success: bool = True
|
| 50 |
+
data: list[StudentResponse]
|
| 51 |
+
meta: Meta
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class MessageData(BaseModel):
|
| 55 |
+
message: str
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class MessageResponseSchema(BaseModel):
|
| 59 |
+
success: bool = True
|
| 60 |
+
data: MessageData
|
| 61 |
+
meta: Meta
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class TokenData(TokenResponse):
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class TokenResponseSchema(BaseModel):
|
| 69 |
+
success: bool = True
|
| 70 |
+
data: TokenData
|
| 71 |
+
meta: Meta
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class UserData(BaseModel):
|
| 75 |
+
id: str
|
| 76 |
+
username: str
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class UserResponseSchema(BaseModel):
|
| 80 |
+
success: bool = True
|
| 81 |
+
data: UserData
|
| 82 |
+
meta: Meta
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ErrorDetail(BaseModel):
|
| 86 |
+
detail: str
|
| 87 |
+
status_code: int
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class ErrorResponseSchema(BaseModel):
|
| 91 |
+
success: bool = False
|
| 92 |
+
data: ErrorDetail
|
| 93 |
+
meta: Meta
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
common_error = {
|
| 97 |
+
401: {"model": ErrorResponseSchema, "description": "Unauthorized"},
|
| 98 |
+
422: {"model": ErrorResponseSchema, "description": "Validation Error"},
|
| 99 |
+
500: {"model": ErrorResponseSchema, "description": "Internal Server Error"},
|
| 100 |
+
}
|
app/presentation/schemas/student.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class StudentCreate(BaseModel):
|
| 7 |
+
nrp: str
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class StudentUpdate(BaseModel):
|
| 11 |
+
hometown: str | None = None
|
| 12 |
+
hobbies: str | None = None
|
| 13 |
+
first_impression: str | None = None
|
| 14 |
+
photo_url: str | None = None
|
| 15 |
+
longitude: float | None = None
|
| 16 |
+
latitude: float | None = None
|
| 17 |
+
captured_at: datetime | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SubmissionRequest(BaseModel):
|
| 21 |
+
nrp: str
|
| 22 |
+
asal_daerah: str
|
| 23 |
+
hobi: list[str]
|
| 24 |
+
first_impression: str
|
| 25 |
+
longitude: float
|
| 26 |
+
latitude: float
|
| 27 |
+
captured_at: datetime
|
| 28 |
+
photo_url: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class StudentResponse(BaseModel):
|
| 32 |
+
id: str
|
| 33 |
+
nrp: str
|
| 34 |
+
name: str | None = None
|
| 35 |
+
major: str | None = None
|
| 36 |
+
hometown: str | None = None
|
| 37 |
+
hobbies: str | None = None
|
| 38 |
+
first_impression: str | None = None
|
| 39 |
+
photo_url: str | None = None
|
| 40 |
+
longitude: float | None = None
|
| 41 |
+
latitude: float | None = None
|
| 42 |
+
captured_at: datetime | None = None
|
| 43 |
+
created_at: datetime
|
| 44 |
+
updated_at: datetime
|
| 45 |
+
|
| 46 |
+
model_config = {"from_attributes": True}
|
migrate.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add missing columns to students table and drop nrp unique constraint."""
|
| 2 |
+
from sqlalchemy import inspect, text
|
| 3 |
+
from app.db.session import engine
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def run():
|
| 7 |
+
inspector = inspect(engine)
|
| 8 |
+
existing_cols = {c["name"] for c in inspector.get_columns("students")}
|
| 9 |
+
indexes = inspector.get_indexes("students")
|
| 10 |
+
dialect = engine.dialect.name
|
| 11 |
+
added = []
|
| 12 |
+
|
| 13 |
+
with engine.connect() as conn:
|
| 14 |
+
if "longitude" not in existing_cols:
|
| 15 |
+
t = "FLOAT" if dialect == "sqlite" else "DOUBLE PRECISION"
|
| 16 |
+
conn.execute(text(f"ALTER TABLE students ADD COLUMN longitude {t}"))
|
| 17 |
+
added.append("longitude")
|
| 18 |
+
|
| 19 |
+
if "latitude" not in existing_cols:
|
| 20 |
+
t = "FLOAT" if dialect == "sqlite" else "DOUBLE PRECISION"
|
| 21 |
+
conn.execute(text(f"ALTER TABLE students ADD COLUMN latitude {t}"))
|
| 22 |
+
added.append("latitude")
|
| 23 |
+
|
| 24 |
+
if "captured_at" not in existing_cols:
|
| 25 |
+
t = "DATETIME" if dialect == "sqlite" else "TIMESTAMP"
|
| 26 |
+
conn.execute(text(f"ALTER TABLE students ADD COLUMN captured_at {t}"))
|
| 27 |
+
added.append("captured_at")
|
| 28 |
+
|
| 29 |
+
if "user_id" not in existing_cols:
|
| 30 |
+
if dialect == "sqlite":
|
| 31 |
+
conn.execute(text("ALTER TABLE students ADD COLUMN user_id VARCHAR"))
|
| 32 |
+
else:
|
| 33 |
+
conn.execute(text("ALTER TABLE students ADD COLUMN user_id VARCHAR REFERENCES users(id)"))
|
| 34 |
+
added.append("user_id")
|
| 35 |
+
|
| 36 |
+
# Drop unique index on nrp (now scoped by user_id)
|
| 37 |
+
for idx in indexes:
|
| 38 |
+
if idx["name"] == "ix_students_nrp" and idx.get("unique"):
|
| 39 |
+
conn.execute(text("DROP INDEX ix_students_nrp"))
|
| 40 |
+
print(" Dropped unique index on nrp")
|
| 41 |
+
break
|
| 42 |
+
|
| 43 |
+
conn.commit()
|
| 44 |
+
|
| 45 |
+
if added:
|
| 46 |
+
print(f"✓ Added columns to {dialect}: {', '.join(added)}")
|
| 47 |
+
else:
|
| 48 |
+
print(f"✓ All columns already exist ({dialect}). Nothing to do.")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
run()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "bukang-api"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Backend API for Bukang - Student Profile Collection"
|
| 5 |
+
requires-python = ">=3.11"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"fastapi>=0.115.0",
|
| 8 |
+
"uvicorn[standard]>=0.34.0",
|
| 9 |
+
"sqlalchemy>=2.0.0",
|
| 10 |
+
"pydantic-settings>=2.7.0",
|
| 11 |
+
"python-jose[cryptography]>=3.3.0",
|
| 12 |
+
"passlib[bcrypt]>=1.7.4",
|
| 13 |
+
"python-multipart>=0.0.18",
|
| 14 |
+
"psycopg2-binary>=2.9.0",
|
| 15 |
+
"huggingface_hub>=0.27.0",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[dependency-groups]
|
| 19 |
+
dev = [
|
| 20 |
+
"pytest>=8.0.0",
|
| 21 |
+
"httpx>=0.28.0",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[tool.pytest.ini_options]
|
| 25 |
+
testpaths = ["tests"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.34.0
|
| 3 |
+
sqlalchemy>=2.0.0
|
| 4 |
+
pydantic-settings>=2.7.0
|
| 5 |
+
python-jose[cryptography]>=3.3.0
|
| 6 |
+
passlib[bcrypt]>=1.7.4
|
| 7 |
+
python-multipart>=0.0.18
|
| 8 |
+
psycopg2-binary>=2.9.0
|
| 9 |
+
huggingface_hub>=0.27.0
|
| 10 |
+
pytest>=8.0.0
|
| 11 |
+
httpx>=0.28.0
|