Skip to content

Commit cb540e1

Browse files
authored
dev to main
Dev to main
2 parents 725ecfc + 667b88c commit cb540e1

20 files changed

Lines changed: 875 additions & 329 deletions

app.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,11 @@
1212

1313
from config import settings
1414
from utils.exceptions import AppException
15-
from routes import auth, survey, matches, cron, profiles
15+
from routes import auth, survey, matches, cron, profiles, admin, verification
1616

1717

1818
def create_app() -> FastAPI:
19-
app = FastAPI(
20-
title="MeteorMate API",
21-
version="1.0.0",
22-
root_path="/api"
23-
)
19+
app = FastAPI(title="MeteorMate API", version="1.0.0", root_path="/api")
2420

2521
# Logging
2622
logger = logging.getLogger("meteormate")
@@ -78,6 +74,8 @@ async def app_exception_handler(request: Request, exc: AppException):
7874
app.include_router(matches.router, prefix="/matches", tags=["matches"])
7975
app.include_router(cron.router, prefix="/cron", tags=["cron"])
8076
app.include_router(profiles.router, prefix="/profiles", tags=["user_profiles"])
77+
app.include_router(admin.router, prefix="/admin", tags=["admin"])
78+
app.include_router(verification.router, prefix="/verification", tags=["verification"])
8179

8280
@app.get("")
8381
async def root_no_slash():

config.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
class Settings:
1010
DATABASE_URL: str = config("DATABASE_URL")
1111

12-
FIREBASE_CREDENTIALS = json.loads(
13-
config("FIREBASE_CREDENTIALS", default="{}")
14-
)
12+
FIREBASE_CREDENTIALS = json.loads(config("FIREBASE_CREDENTIALS", default="{}"))
1513
FIREBASE_STORAGE_BUCKET: str = config("FIREBASE_STORAGE_BUCKET", default="")
1614

1715
ALLOWED_ORIGINS: List[str] = ["*"] # todo - change this to meteormate.com when site is live
@@ -30,12 +28,6 @@ class Settings:
3028
# cron config (i.e. admin token for cron jobs to perform administrative duties)
3129
CRON_SECRET: str = config("CRON_SECRET", default="")
3230

33-
# admin bearer key for testing
34-
ADMIN_BEARER: str = config("ADMIN_BEARER", default="")
35-
36-
# admin user uid for testing
37-
ADMIN_UID: str = config("ADMIN_UID", default="")
38-
3931
# validation config
4032
FIRST_NAME_MIN_LEN: int = 2
4133
FIRST_NAME_MAX_LEN: int = 50

database.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def to_db(v):
3131
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
3232

3333
except Exception as e:
34-
logger.critical(f"Failed to initialize database engine: {str(e)}")
34+
logger.critical(f"Failed to initialize database engine: {str(e)}", exc_info=settings.DEBUG)
3535
raise
3636

3737
ORMBase = declarative_base()
@@ -73,15 +73,22 @@ def commit_or_raise(
7373
orig = e.orig
7474

7575
if isinstance(orig, ForeignKeyViolation):
76-
route_logger.exception(
77-
f"{action} failed: foreign key violation on {resource} (User: {uid})"
76+
route_logger.error(
77+
f"{action} failed: foreign key violation on {resource} (User: {uid})",
78+
exc_info=settings.DEBUG,
7879
)
7980
raise NotFound(resource)
8081

81-
route_logger.exception(f"{action} failed: integrity conflict on {resource} (User: {uid})")
82+
route_logger.error(
83+
f"{action} failed: integrity conflict on {resource} (User: {uid})",
84+
exc_info=settings.DEBUG
85+
)
8286
raise Conflict(f"{resource} conflicts with existing data")
8387

8488
except SQLAlchemyError:
8589
db.rollback()
86-
route_logger.exception(f"{action} failed: unexpected DB error on {resource} (User: {uid})")
90+
route_logger.error(
91+
f"{action} failed: unexpected DB error on {resource} (User: {uid})",
92+
exc_info=settings.DEBUG
93+
)
8794
raise InternalServerError(f"Internal server error while {action} {resource}")

models/admin.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from datetime import datetime
2+
from sqlalchemy import DateTime, Text, func
3+
from sqlalchemy.orm import mapped_column, Mapped
4+
from database import ORMBase
5+
6+
7+
class Banlist(ORMBase):
8+
__tablename__ = "banlist"
9+
10+
net_id: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True)
11+
created_at: Mapped[datetime] = mapped_column(
12+
DateTime(timezone=True), nullable=False, server_default=func.now()
13+
)
14+
15+
16+
class Admins(ORMBase):
17+
__tablename__ = "admins"
18+
19+
net_id: Mapped[str] = mapped_column(Text, nullable=False, primary_key=True)
20+
created_at: Mapped[datetime] = mapped_column(
21+
DateTime(timezone=True), nullable=False, server_default=func.now()
22+
)

models/downscale.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import sys
2+
import os
3+
import tkinter as tk
4+
from tkinter import filedialog
5+
from PIL import Image
6+
7+
NORMAL_MAX_BYTES = 10 * 1024 * 1024
8+
AGGRESSIVE_MAX_BYTES = 5 * 1024 * 1024
9+
OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__))
10+
11+
12+
def get_file_size(path):
13+
return os.path.getsize(path)
14+
15+
16+
def downscale_image(input_path, max_bytes):
17+
if not os.path.isfile(input_path):
18+
print(f"File not found: {input_path}")
19+
return None
20+
21+
file_size = get_file_size(input_path)
22+
filename = os.path.basename(input_path)
23+
name, ext = os.path.splitext(filename)
24+
output_ext = ext.lower() if ext.lower() in (".jpg", ".jpeg", ".png", ".webp") else ".jpg"
25+
output_path = os.path.join(OUTPUT_DIR, f"{name}_downscaled{output_ext}")
26+
27+
img = Image.open(input_path)
28+
29+
if img.mode == "RGBA" and output_ext in (".jpg", ".jpeg"):
30+
img = img.convert("RGB")
31+
elif img.mode not in ("RGB", "RGBA"):
32+
img = img.convert("RGB")
33+
34+
if file_size <= max_bytes:
35+
img.save(output_path, quality=95)
36+
print(f"Image already under limit. Saved to: {output_path}")
37+
return output_path
38+
39+
quality = 95
40+
scale = 1.0
41+
42+
img.save(output_path, quality=quality, optimize=True)
43+
44+
while get_file_size(output_path) > max_bytes and quality > 20:
45+
quality -= 5
46+
img.save(output_path, quality=quality, optimize=True)
47+
48+
if get_file_size(output_path) <= max_bytes:
49+
print(f"Downscaled (quality={quality}): {output_path}")
50+
return output_path
51+
52+
while get_file_size(output_path) > max_bytes and scale > 0.1:
53+
scale -= 0.1
54+
new_width = int(img.width * scale)
55+
new_height = int(img.height * scale)
56+
resized = img.resize((new_width, new_height), Image.LANCZOS)
57+
resized.save(output_path, quality=quality, optimize=True)
58+
59+
print(f"Downscaled (quality={quality}, scale={scale:.0%}): {output_path}")
60+
return output_path
61+
62+
63+
def pick_images():
64+
root = tk.Tk()
65+
root.withdraw()
66+
root.attributes('-topmost', True)
67+
root.update()
68+
paths = filedialog.askopenfilenames(
69+
title="Select images to downscale",
70+
filetypes=[
71+
("Image files", "*.jpg *.jpeg *.png *.webp *.bmp *.tiff"),
72+
("All files", "*.*"),
73+
],
74+
)
75+
root.destroy()
76+
return paths
77+
78+
79+
def pick_mode():
80+
print("Select downscale mode:")
81+
print(" 1) Normal - 10MB or less")
82+
print(" 2) Aggressive - 5MB or less")
83+
choice = input("Enter 1 or 2: ").strip()
84+
if choice == "2":
85+
return AGGRESSIVE_MAX_BYTES
86+
return NORMAL_MAX_BYTES
87+
88+
89+
if __name__ == "__main__":
90+
max_bytes = pick_mode()
91+
92+
if len(sys.argv) >= 2:
93+
files = sys.argv[1:]
94+
else:
95+
files = pick_images()
96+
97+
if not files:
98+
print("No images selected.")
99+
sys.exit(0)
100+
101+
for path in files:
102+
downscale_image(path, max_bytes)

models/user.py

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Optional, Literal
66

77
from pydantic import BaseModel, EmailStr
8-
from sqlalchemy import Column, Boolean, DateTime, Text, func, Enum
8+
from sqlalchemy import Column, Boolean, DateTime, Text, func, Enum as SQLEnum
99
from sqlalchemy.orm import relationship
1010

1111
from database import ORMBase
@@ -26,13 +26,21 @@ class User(ORMBase):
2626

2727
# behind-the-scenes stuff
2828
is_active = Column(Boolean, nullable=False, server_default='true', default=True)
29-
pending_deletion = Column(Boolean, nullable=False, server_default='false', default=True)
29+
pending_deletion = Column(Boolean, nullable=False, server_default='false', default=False)
30+
is_banned = Column(Boolean, nullable=False, server_default='false', default=False)
3031

3132
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
3233
updated_at = Column(
3334
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
3435
)
35-
inactivity_notification_stage = Column(Enum(InactivityStage), nullable=True)
36+
inactivity_notification_stage = Column(
37+
SQLEnum(
38+
InactivityStage,
39+
values_callable=lambda enum_cls: [e.value for e in enum_cls],
40+
name="inactivitystage",
41+
),
42+
nullable=True,
43+
)
3644
last_inactivity_notification_sent_at = Column(DateTime, nullable=True)
3745

3846
survey = relationship(
@@ -41,20 +49,3 @@ class User(ORMBase):
4149
profile = relationship(
4250
"UserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan"
4351
)
44-
45-
46-
class UserRequestVerify(BaseModel):
47-
email: EmailStr
48-
uid: Optional[str] = None
49-
purpose: Literal["verify", "reset"] = "verify"
50-
51-
52-
class UserCompleteVerify(BaseModel):
53-
email: EmailStr
54-
code: str
55-
56-
57-
class UserResetPassword(BaseModel):
58-
email: EmailStr
59-
code: str
60-
new_password: str

models/user_profile.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Created by Ryan Polasky | 9/20/25
22
# ACM MeteorMate | All Rights Reserved
33

4-
from sqlalchemy import ARRAY, Column, DateTime, Text, ForeignKey, func, Numeric
4+
import enum
5+
from sqlalchemy import ARRAY, Column, Text, ForeignKey, func, Numeric, Enum as SQLEnum, Boolean, text, Date, DateTime
56
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
67
from sqlalchemy.orm import relationship
78
from sqlalchemy.ext.mutable import MutableList
@@ -37,13 +38,19 @@ class UserProfile(ORMBase):
3738
major = Column(Text)
3839
classification = Column(CLASSIFICATION_ENUM)
3940
bio = Column(Text)
40-
profile_picture_url = Column(MutableList.as_mutable(ARRAY(Text)), default=[])
41+
profile_picture_url = Column(MutableList.as_mutable(ARRAY(Text)), default=list)
4142

4243
# moved from user table
4344
first_name = Column(Text)
4445
last_name = Column(Text)
4546
age = Column(Numeric)
4647

48+
dob = Column(Date(), nullable=False)
49+
50+
# notification preferences
51+
match_notification = Column(Boolean, server_default=text("true"), nullable=False)
52+
promotional_notification = Column(Boolean, server_default=text("false"), nullable=False)
53+
4754
# behind-the-scenes stuff
4855
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
4956
updated_at = Column(

0 commit comments

Comments
 (0)