XRyZ commited on
Commit
01c6926
·
verified ·
1 Parent(s): cd5e36f

Upload folder using huggingface_hub

Browse files
Files changed (47) hide show
  1. .dockerignore +9 -0
  2. Dockerfile +12 -0
  3. README.md +5 -7
  4. app/__init__.py +0 -0
  5. app/application/__init__.py +0 -0
  6. app/application/interfaces/__init__.py +0 -0
  7. app/application/interfaces/jwt.py +11 -0
  8. app/application/interfaces/password.py +11 -0
  9. app/application/use_cases/__init__.py +0 -0
  10. app/application/use_cases/auth_use_case.py +81 -0
  11. app/application/use_cases/student_use_case.py +493 -0
  12. app/config.py +17 -0
  13. app/core/__init__.py +0 -0
  14. app/core/logging.py +8 -0
  15. app/db/__init__.py +0 -0
  16. app/db/base.py +5 -0
  17. app/db/session.py +19 -0
  18. app/domain/__init__.py +0 -0
  19. app/domain/entities/__init__.py +0 -0
  20. app/domain/entities/user.py +18 -0
  21. app/domain/repositories/__init__.py +0 -0
  22. app/domain/repositories/user_repository.py +17 -0
  23. app/infrastructure/__init__.py +0 -0
  24. app/infrastructure/persistence/__init__.py +0 -0
  25. app/infrastructure/persistence/models/__init__.py +0 -0
  26. app/infrastructure/persistence/models/student.py +25 -0
  27. app/infrastructure/persistence/models/user.py +15 -0
  28. app/infrastructure/persistence/repositories/__init__.py +0 -0
  29. app/infrastructure/persistence/repositories/user_repository.py +43 -0
  30. app/infrastructure/security/__init__.py +0 -0
  31. app/infrastructure/security/jwt.py +26 -0
  32. app/infrastructure/security/password.py +11 -0
  33. app/infrastructure/storage/__init__.py +0 -0
  34. app/infrastructure/storage/hf_storage.py +46 -0
  35. app/main.py +101 -0
  36. app/presentation/__init__.py +0 -0
  37. app/presentation/dependencies.py +57 -0
  38. app/presentation/routers/__init__.py +0 -0
  39. app/presentation/routers/auth.py +69 -0
  40. app/presentation/routers/students.py +107 -0
  41. app/presentation/schemas/__init__.py +0 -0
  42. app/presentation/schemas/auth.py +21 -0
  43. app/presentation/schemas/response.py +100 -0
  44. app/presentation/schemas/student.py +46 -0
  45. migrate.py +52 -0
  46. pyproject.toml +25 -0
  47. 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: yellow
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
- license: mit
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