Skip to content

Commit fae46a7

Browse files
orabeCopilot
andcommitted
Enhance certificate management: add QR code generation, improve deletion scripts, and update README with new features
Co-authored-by: Copilot <copilot@github.com>
1 parent 017c798 commit fae46a7

12 files changed

Lines changed: 315 additions & 66 deletions

backend/README.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,51 @@ backend/
6565
## Issuing a Certificate
6666
- Use the admin API or run `python scripts/create_certificate.py` interactively.
6767
- The script now asks for a `student_id` primary key and the student name.
68+
- After creation, it also generates a QR image in `backend/assets/qrcodes/` using the public verification URL.
6869

6970
## Removing a Certificate
70-
- Run `python scripts/remove_certificate.py S12345`
71+
- Preview deletion by student ID:
72+
```bash
73+
./venv/bin/python scripts/remove_certificate.py --student S12345
74+
```
75+
- Preview deletion by certificate ID:
76+
```bash
77+
./venv/bin/python scripts/remove_certificate.py --certificate MCL-2026-XXXXXXX
78+
```
79+
- Execute deletion with confirmation:
80+
```bash
81+
./venv/bin/python scripts/remove_certificate.py --student S12345 --yes
82+
./venv/bin/python scripts/remove_certificate.py --certificate MCL-2026-XXXXXXX --yes
83+
```
84+
- The script removes certificates only. It does not delete the student row.
85+
86+
## Cleaning Broken Student IDs
87+
- Use this when rows contain `NULL`, empty, or literal `None` in `student_id`.
88+
- Dry-run:
89+
```bash
90+
./venv/bin/python scripts/clean_none_students.py
91+
```
92+
- Execute cleanup:
93+
```bash
94+
./venv/bin/python scripts/clean_none_students.py --yes
95+
```
96+
- This script deletes matching certificates and matching student rows.
7197

7298
## Looking Up a Student
73-
- Run `python scripts/view_student.py S12345`
99+
- Run `python scripts/view_student.py --student S12345`
74100
- Or call the admin API: `GET /admin/students/{student_id}`
75101

102+
## Viewing Database Data
103+
- Show the latest certificates:
104+
```bash
105+
./venv/bin/python scripts/view_database.py
106+
```
107+
- Show a specific student and all of their certificates:
108+
```bash
109+
./venv/bin/python scripts/view_student.py --student S12345
110+
```
111+
- Both viewers print dates without time, using `YYYY.MM.DD`.
112+
76113
## Revocation
77114
Revocation support has been removed from this codebase. Certificates cannot be revoked via the API.
78115

@@ -102,6 +139,11 @@ Revocation support has been removed from this codebase. Certificates cannot be r
102139
- (Optional) QR code to the same URL
103140
- The verification page confirms MathCodeLab as the issuer, not external accreditation.
104141

142+
## QR Images
143+
- QR images are generated server-side as transparent PNG files.
144+
- Default output path: `backend/assets/qrcodes/{student_id}_{certificate_id}.png`
145+
- The QR payload is the public verification URL only.
146+
105147
## Wording Policy
106148
- Use “Certificate of Completion” or “Certificate of Participation”.
107149
- Do **not** use “accredited”, “state-recognized”, “official degree”, or “diploma”.

backend/app/crud.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from sqlalchemy.orm import Session
22
from . import models, schemas
3-
from datetime import datetime
3+
from datetime import datetime, date
44
import random, string
55

66
def generate_certificate_id(year: int) -> str:
@@ -47,6 +47,7 @@ def create_certificate(db: Session, cert_in: schemas.CertificateCreate):
4747
course_title=cert_in.course_title,
4848
completion_date=cert_in.completion_date,
4949
duration_hours=cert_in.duration_hours,
50+
created_at=_parse_created_at(cert_in.created_at),
5051
attendance_percentage=cert_in.attendance_percentage,
5152
assignment_completion_percentage=cert_in.assignment_completion_percentage,
5253
course_level=cert_in.course_level,
@@ -62,6 +63,19 @@ def create_certificate(db: Session, cert_in: schemas.CertificateCreate):
6263
db.refresh(cert)
6364
return cert
6465

66+
67+
def _parse_created_at(value: str | None):
68+
"""Parse a YYYY-MM-DD string and return a date object.
69+
70+
The system stores only the date (year-month-day). Return a
71+
`datetime.date` instance or None.
72+
"""
73+
if not value:
74+
return None
75+
76+
parsed = datetime.strptime(value, "%Y-%m-%d")
77+
return date(parsed.year, parsed.month, parsed.day)
78+
6579
def revoke_certificate(db: Session, certificate_id: str, reason: str = None):
6680
cert = get_certificate_by_public_id(db, certificate_id)
6781
if not cert:

backend/app/database.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,16 @@ def ensure_certificate_schema(bind=None):
5252
if "student_name" not in student_columns:
5353
missing_statements.append("ALTER TABLE students ADD COLUMN student_name VARCHAR")
5454
if "created_at" not in student_columns:
55-
missing_statements.append("ALTER TABLE students ADD COLUMN created_at TIMESTAMP")
55+
missing_statements.append("ALTER TABLE students ADD COLUMN created_at DATE")
5656
if "updated_at" not in student_columns:
5757
missing_statements.append("ALTER TABLE students ADD COLUMN updated_at TIMESTAMP")
5858

5959
if "student_id" not in existing_columns:
6060
missing_statements.append("ALTER TABLE certificates ADD COLUMN student_id VARCHAR")
6161

62+
if "created_at" not in existing_columns:
63+
missing_statements.append("ALTER TABLE certificates ADD COLUMN created_at DATE")
64+
6265
if "attendance_percentage" not in existing_columns:
6366
missing_statements.append("ALTER TABLE certificates ADD COLUMN attendance_percentage INTEGER")
6467
if "assignment_completion_percentage" not in existing_columns:

backend/app/main.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from fastapi.middleware.cors import CORSMiddleware
44
from sqlalchemy.orm import Session
55
from starlette.responses import JSONResponse
6-
from datetime import datetime
6+
from datetime import datetime, date
77
from . import models, schemas, crud, database, security
88
import os
99

@@ -67,6 +67,13 @@ def verify_certificate(certificate_id: str, db: Session = Depends(get_db)):
6767

6868
student = getattr(cert, "student", None)
6969

70+
def _format_ts(val):
71+
if val is None:
72+
return None
73+
if isinstance(val, (datetime, date)):
74+
return val.isoformat()
75+
return str(val)
76+
7077
return {
7178
"status": "valid",
7279
"certificate_id": cert.certificate_id,
@@ -83,10 +90,7 @@ def verify_certificate(certificate_id: str, db: Session = Depends(get_db)):
8390
"course_level": cert.course_level,
8491
"course_format": cert.course_format,
8592
"instruction_language": cert.instruction_language,
86-
"certificate_created_at": cert.created_at,
87-
"certificate_updated_at": cert.updated_at,
88-
"student_created_at": getattr(student, "created_at", None) if student else None,
89-
"student_updated_at": getattr(student, "updated_at", None) if student else None,
93+
"certificate_created_at": _format_ts(cert.created_at),
9094
"verified_at": datetime.utcnow().isoformat() + "Z",
9195
"verification_url": f"https://mathcodelab.de/verify/?id={cert.certificate_id}"
9296
}
@@ -107,6 +111,13 @@ def list_certificates(
107111
api_key: str = Depends(security.verify_api_key)
108112
):
109113
certs = db.query(models.Certificate).all()
114+
def _format_ts(val):
115+
if val is None:
116+
return None
117+
if isinstance(val, (datetime, date)):
118+
return val.isoformat()
119+
return str(val)
120+
110121
return [
111122
{
112123
"certificate_id": cert.certificate_id,
@@ -123,8 +134,7 @@ def list_certificates(
123134
"instruction_language": cert.instruction_language,
124135
"issuer": cert.issuer,
125136
"instructor": cert.instructor,
126-
"created_at": cert.created_at,
127-
"updated_at": cert.updated_at,
137+
"created_at": _format_ts(cert.created_at),
128138
}
129139
for cert in certs
130140
]
@@ -147,12 +157,17 @@ def get_student_by_student_id(
147157
.all()
148158
)
149159

160+
def _format_ts(val):
161+
if val is None:
162+
return None
163+
if isinstance(val, (datetime, date)):
164+
return val.isoformat()
165+
return str(val)
166+
150167
return {
151168
"student": {
152169
"student_id": student.student_id,
153170
"student_name": student.student_name,
154-
"created_at": student.created_at,
155-
"updated_at": student.updated_at,
156171
},
157172
"certificates": certificates,
158173
}

backend/app/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
1+
from sqlalchemy import Column, Integer, String, DateTime, Date, ForeignKey
22
from sqlalchemy.orm import relationship
33
from sqlalchemy.sql import func
44
from .database import Base
@@ -7,7 +7,7 @@ class Student(Base):
77
__tablename__ = "students"
88
student_id = Column(String, primary_key=True, index=True, nullable=False)
99
student_name = Column(String, nullable=False)
10-
created_at = Column(DateTime(timezone=True), server_default=func.now())
10+
created_at = Column(Date, server_default=func.current_date())
1111
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
1212

1313
class Certificate(Base):
@@ -30,6 +30,6 @@ class Certificate(Base):
3030
issuer = Column(String, default="MathCodeLab", nullable=False)
3131
instructor = Column(String, default="Mohammad Orabe", nullable=False)
3232
student = relationship("Student")
33-
created_at = Column(DateTime(timezone=True), server_default=func.now())
33+
created_at = Column(Date, server_default=func.current_date())
3434
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
3535

backend/app/qr.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
import re
3+
4+
try:
5+
import qrcode
6+
except ModuleNotFoundError as exc:
7+
raise ModuleNotFoundError(
8+
"qrcode is required for QR generation. Install backend requirements first."
9+
) from exc
10+
11+
12+
PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__))
13+
DEFAULT_QR_DIR = os.path.join(PROJECT_ROOT, "assets", "qrcodes")
14+
DEFAULT_VERIFICATION_BASE_URL = os.getenv(
15+
"VERIFICATION_BASE_URL",
16+
"https://mathcodelab.de/verify/?id=",
17+
)
18+
19+
20+
def _safe_filename_part(value: str) -> str:
21+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip())
22+
return cleaned.strip("._-") or "certificate"
23+
24+
25+
def build_verification_url(certificate_id: str, base_url: str | None = None) -> str:
26+
base = (base_url or DEFAULT_VERIFICATION_BASE_URL).rstrip()
27+
if not base.endswith("="):
28+
base = base.rstrip("/") + "/"
29+
return f"{base}{certificate_id}"
30+
31+
32+
def generate_certificate_qr(
33+
certificate_id: str,
34+
student_id: str | None = None,
35+
verification_url: str | None = None,
36+
output_dir: str | None = None,
37+
) -> str:
38+
"""Generate a QR image for the certificate verification URL and return its file path."""
39+
target_url = verification_url or build_verification_url(certificate_id)
40+
qr = qrcode.QRCode(
41+
version=None,
42+
error_correction=qrcode.constants.ERROR_CORRECT_M,
43+
box_size=10,
44+
border=4,
45+
)
46+
qr.add_data(target_url)
47+
qr.make(fit=True)
48+
49+
image = qr.make_image(fill_color="black", back_color="white").convert("RGBA")
50+
pixels = image.getdata()
51+
transparent_pixels = []
52+
for red, green, blue, alpha in pixels:
53+
if red >= 250 and green >= 250 and blue >= 250:
54+
transparent_pixels.append((255, 255, 255, 0))
55+
else:
56+
transparent_pixels.append((red, green, blue, 255))
57+
image.putdata(transparent_pixels)
58+
59+
target_dir = output_dir or DEFAULT_QR_DIR
60+
os.makedirs(target_dir, exist_ok=True)
61+
filename_parts = []
62+
if student_id:
63+
filename_parts.append(_safe_filename_part(student_id))
64+
filename_parts.append(_safe_filename_part(certificate_id))
65+
file_path = os.path.join(target_dir, "_".join(filename_parts) + ".png")
66+
image.save(file_path)
67+
return file_path

backend/app/schemas.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class CertificateBase(BaseModel):
77
course_title: str
88
completion_date: str
99
duration_hours: int
10+
created_at: Optional[str] = None
1011
attendance_percentage: Optional[int] = None
1112
assignment_completion_percentage: Optional[int] = None
1213
course_level: Optional[str] = None
@@ -28,8 +29,6 @@ class Config:
2829
class StudentOut(BaseModel):
2930
student_id: str
3031
student_name: str
31-
created_at: Optional[str] = None
32-
updated_at: Optional[str] = None
3332

3433

3534
class StudentLookupResponse(BaseModel):
@@ -55,12 +54,8 @@ class CertificateVerificationResponse(BaseModel):
5554
course_level: Optional[str] = None
5655
course_format: Optional[str] = None
5756
instruction_language: Optional[str] = None
58-
# Certificate timestamps
57+
# Certificate creation date (date-only)
5958
certificate_created_at: Optional[str] = None
60-
certificate_updated_at: Optional[str] = None
61-
# Student timestamps
62-
student_created_at: Optional[str] = None
63-
student_updated_at: Optional[str] = None
6459

6560

6661
class CertificateDeleteResponse(BaseModel):

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ sqlalchemy
44
pydantic
55
python-dotenv
66
psycopg2
7+
qrcode[pil]

0 commit comments

Comments
 (0)