From 5e135c786b6e473fca49b6109b45fa88ca493529 Mon Sep 17 00:00:00 2001 From: Dilan Gilluly Date: Fri, 1 May 2026 17:43:48 -0400 Subject: [PATCH] Tickets working --- .gitignore | 2 + apiserver/launch_dev.sh | 3 ++ apiserver/requirements.txt | 1 + apiserver/src/__init__.py | 0 apiserver/src/core/auth.py | 95 ++++++++++++++++++++++++++++++++++ apiserver/src/core/db.py | 60 +++++++++++++++++++++ apiserver/src/core/models.py | 20 +++++++ apiserver/src/core/settings.py | 29 +++++++++++ apiserver/src/data/models.py | 56 ++++++++++++++++++++ apiserver/src/data/repos.py | 75 +++++++++++++++++++++++++++ apiserver/src/data/routers.py | 63 ++++++++++++++++++++++ apiserver/src/main.py | 29 +++++++++++ apiserver/src/routers.py | 9 ++++ 13 files changed, 442 insertions(+) create mode 100644 .gitignore create mode 100644 apiserver/launch_dev.sh create mode 100644 apiserver/requirements.txt create mode 100644 apiserver/src/__init__.py create mode 100644 apiserver/src/core/auth.py create mode 100644 apiserver/src/core/db.py create mode 100644 apiserver/src/core/models.py create mode 100644 apiserver/src/core/settings.py create mode 100644 apiserver/src/data/models.py create mode 100644 apiserver/src/data/repos.py create mode 100644 apiserver/src/data/routers.py create mode 100644 apiserver/src/main.py create mode 100644 apiserver/src/routers.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c108ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +apiserver/data/ +*__pycache__/ diff --git a/apiserver/launch_dev.sh b/apiserver/launch_dev.sh new file mode 100644 index 0000000..b50e142 --- /dev/null +++ b/apiserver/launch_dev.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +fastapi dev ./src/main.py diff --git a/apiserver/requirements.txt b/apiserver/requirements.txt new file mode 100644 index 0000000..13712cc --- /dev/null +++ b/apiserver/requirements.txt @@ -0,0 +1 @@ +fastapi[standard] diff --git a/apiserver/src/__init__.py b/apiserver/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apiserver/src/core/auth.py b/apiserver/src/core/auth.py new file mode 100644 index 0000000..f0a09c6 --- /dev/null +++ b/apiserver/src/core/auth.py @@ -0,0 +1,95 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status + +from .models import AuthKey, AuthReq, RepoTemplate +from .settings import get_config + +invalid_pw = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password" +) + + +class AuthRepo(RepoTemplate): + def create_key(self, description: str): + while True: + key = AuthKey(description=description) + self.cur.execute("SELECT * FROM auth WHERE auth_key = ?", (key.auth_key,)) + result = self.cur.fetchall() + if len(result) == 0: + self.cur.execute( + "INSERT INTO auth VALUES (?, ?)", (key.auth_key, key.description) + ) + self.conn.commit() + break + return key + + def check_key(self, key: str | None): + self.cur.execute("SELECT * FROM auth WHERE auth_key = ?", (key,)) + result = self.cur.fetchall() + if len(result) > 0: + return True + else: + return False + + def verify_key(self, key: str | None): + self.cur.execute("SELECT * FROM auth WHERE auth_key = ?", (key,)) + result = self.cur.fetchall() + if len(result) == 0: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Auth Key" + ) + + def list_keys(self): + self.cur.execute("SELECT * FROM auth") + results = self.cur.fetchall() + return [AuthKey(*r) for r in results] + + def del_key(self, key: str): + self.cur.execute("DELETE FROM auth WHERE auth_key = ?", (key,)) + self.conn.commit() + return {"detail": "Key deleted if exists."} + + +auth_router = APIRouter(prefix="/api/auth") + + +@auth_router.get("") +def list_auth_keys( + request: Request, + config: dict = Depends(get_config), + tam_auth_pw: Optional[str] = Header(None), +): + auth_pw = tam_auth_pw + if auth_pw == config["api_pw"]: + return AuthRepo().list_keys() + else: + raise invalid_pw + + +@auth_router.post("") +def req_key( + key_req: AuthReq, + config: dict = Depends(get_config), + tam_auth_pw: Optional[str] = Header(None), +): + auth_pw = tam_auth_pw + if auth_pw == config["api_pw"]: + key = AuthRepo().create_key(key_req.description) + return key + else: + raise invalid_pw + + +@auth_router.delete("") +def del_key( + request: Request, + config: dict = Depends(get_config), + del_key: str = "", + tam_auth_pw: Optional[str] = Header(None), +): + auth_pw = tam_auth_pw + if auth_pw == config["api_pw"]: + return AuthRepo().del_key(del_key) + else: + raise invalid_pw diff --git a/apiserver/src/core/db.py b/apiserver/src/core/db.py new file mode 100644 index 0000000..5067066 --- /dev/null +++ b/apiserver/src/core/db.py @@ -0,0 +1,60 @@ +import sqlite3 + +from ..core.settings import get_data_path + +def create_session(): + path = get_data_path() / "./tam.db" + conn = sqlite3.connect(path) + cur = conn.cursor() + return conn, cur + +def init_db(): + conn, cur = create_session() + cur.execute("""CREATE TABLE IF NOT EXISTS auth (auth_key TEXT PRIMARY KEY, description TEXT)""") + cur.execute("""CREATE TABLE IF NOT EXISTS prefixes ( + prefix TEXT PRIMARY KEY, + color TEXT, + weight INT + )""") + cur.execute("""CREATE TABLE IF NOT EXISTS tickets ( + prefix TEXT, + ticket_id INT, + first_name TEXT, + last_name TEXT, + phone_number TEXT, + pref TEXT, + PRIMARY KEY (prefix, ticket_id) + )""") + cur.execute("""CREATE TABLE IF NOT EXISTS baskets ( + prefix TEXT, + basket_id INT, + description TEXT, + winning_ticket INT, + PRIMARY KEY (prefix, basket_id) + )""") + cur.execute("""CREATE TABLE IF NOT EXISTS donors ( + donor_id INTEGER PRIMARY KEY AUTOINCREMENT, + donor_name TEXT, + donor_business TEXT + )""") + cur.execute("""CREATE TABLE IF NOT EXISTS r_basket_donor ( + b_prefix TEXT REFERENCES baskets(prefix), + b_id INT REFERENCES baskets(basket_id), + d_id INT REFERENCES donors(donor_id), + PRIMARY KEY (b_prefix, b_id, d_id) + )""") + cur.execute("""CREATE VIEW IF NOT EXISTS winners_by_basket AS + SELECT b.prefix, b.basket_id, b.description, b.winning_ticket, t.last_name, t.first_name, t.phone_number, t.pref + FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.ticket_id + ORDER BY b.prefix, b.basket_id""") + cur.execute("""CREATE VIEW IF NOT EXISTS winners_by_name AS + SELECT b.prefix, t.last_name, t.first_name, t.phone_number, b.basket_id, b.description, t.pref + FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.ticket_id + ORDER BY b.prefix, t.last_name, t.first_name, t.phone_number, b.basket_id""") + cur.execute("""CREATE VIEW IF NOT EXISTS counts AS + SELECT prefix, COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))) AS unique_buyers, COUNT(*) AS total_buys + GROUP BY prefix + UNION ALL + SELECT 'Total', COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))), COUNT(*)""") + conn.commit() + print("DB initiated.") diff --git a/apiserver/src/core/models.py b/apiserver/src/core/models.py new file mode 100644 index 0000000..377d465 --- /dev/null +++ b/apiserver/src/core/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +import string +import random as r + +from .db import create_session + +class RepoTemplate: + def __init__(self): + self.conn, self.cur = create_session() + +choose_from = string.ascii_uppercase + string.digits + +@dataclass +class AuthKey: + auth_key: str = "".join(r.choice(choose_from) for _ in range(24)) + description: str = "" + +@dataclass +class AuthReq: + description: str = "" diff --git a/apiserver/src/core/settings.py b/apiserver/src/core/settings.py new file mode 100644 index 0000000..014a729 --- /dev/null +++ b/apiserver/src/core/settings.py @@ -0,0 +1,29 @@ +import json +import os +from pathlib import Path + +def get_data_path(): + data_path = Path(os.getenv("TAM_DATA_PATH", "./data")) + data_path = data_path.expanduser() + if not data_path.is_dir(): + data_path.mkdir(parents=True) + return data_path + +def get_settings_path(): + data_path = get_data_path() + settings_path = data_path / "./settings.json" + return settings_path + +def get_config(): + path = get_settings_path() + if path.is_file(): + with open(path, "r") as f: + return json.load(f) + else: + default_settings = { + "mode": os.getenv("TAM_MODE", "prod"), + "api_pw": os.getenv("TAM_API_PW", "tam") + } + with open(path, "w") as f: + json.dump(default_settings, f) + return default_settings diff --git a/apiserver/src/data/models.py b/apiserver/src/data/models.py new file mode 100644 index 0000000..b9f555d --- /dev/null +++ b/apiserver/src/data/models.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass + +@dataclass +class Prefix: + prefix: str = "" + color: str = "" + weight: int = 0 + +@dataclass +class Ticket: + prefix: str = "" + ticket_id: int = 0 + first_name: str = "" + last_name: str = "" + phone_number: str = "" + pref: str = "" + +@dataclass +class Basket: + prefix: str = "" + basket_id: int = 0 + description: str = "" + winning_ticket: int = 0 + +@dataclass +class Donor: + donor_id: int = 0 + donor_name: str = "" + donor_business: str = "" + +@dataclass +class BasketDonorRel: + b_prefix: str = "" + b_id: int = 0 + d_id: int = 0 + +@dataclass +class WinnerByBasket: + prefix: str = "" + basket_id: int = 0 + description: str = "" + winning_ticket: int = 0 + last_name: str = "" + first_name: str = "" + phone_number: str = "" + pref: str = "" + +@dataclass +class WinnerByName: + prefix: str = "" + last_name: str = "" + first_name: str = "" + phone_number: str = "" + basket_id: int = 0 + description: str = "" + pref: str = "" diff --git a/apiserver/src/data/repos.py b/apiserver/src/data/repos.py new file mode 100644 index 0000000..ad6ab04 --- /dev/null +++ b/apiserver/src/data/repos.py @@ -0,0 +1,75 @@ +from ..core.models import RepoTemplate +from .models import Prefix, Ticket + + +class PrefixRepo(RepoTemplate): + """Repo that controls prefixes.""" + + def get_prefixes(self): + """Returns all prefixes.""" + self.cur.execute("SELECT * FROM prefixes ORDER BY weight, prefix") + results = self.cur.fetchall() + return [Prefix(*r) for r in results] + + def get_one_prefix(self, prefix: str): + """Returns one prefix.""" + self.cur.execute("SELECT * FROM prefixes WHERE prefix = ?", (prefix,)) + result = self.cur.fetchone() + if result: + return Prefix(*result) + else: + return Prefix(prefix) + + def post_prefix(self, p: Prefix): + """Posts a prefix in the database.""" + self.cur.execute( + "INSERT INTO prefixes VALUES (?, ?, ?) ON CONFLICT (prefix) DO UPDATE SET color = EXCLUDED.color, weight = EXCLUDED.weight", + (p.prefix, p.color, p.weight), + ) + self.conn.commit() + return {"detail": "Prefix posted successfully."} + + def del_prefix(self, prefix: str): + """Deletes a prefix from the database.""" + self.cur.execute("DELETE FROM prefixes WHERE prefix = ?", (prefix,)) + self.conn.commit() + return {"detail": "Prefix deleted successfully."} + +class TicketRepo(RepoTemplate): + """Repo that controls ticket operations.""" + + def get_all_tickets(self): + """Gets all tickets and returns them.""" + self.cur.execute("SELECT * FROM tickets ORDER BY prefix, ticket_id") + results = self.cur.fetchall() + return [Ticket(*r) for r in results] + + def get_prefix_tickets(self, prefix: str): + """Gets all tickets of a certain prefix.""" + self.cur.execute("SELECT * FROM tickets WHERE prefix = ? ORDER BY prefix, ticket_id", (prefix,)) + results = self.cur.fetchall() + return [Ticket(*r) for r in results] + + def get_one_ticket(self, prefix: str, t_id: int): + """Gets one particular ticket.""" + self.cur.execute("SELECT * FROM tickets WHERE prefix = ? AND ticket_id = ?", (prefix, t_id)) + result = self.cur.fetchone() + if result: + return Ticket(*result) + else: + return Ticket(prefix, t_id) + + def get_range_tickets(self, prefix: str, r_start: int, r_end: int): + """Gets a range of tickets.""" + self.cur.execute("SELECT * FROM tickets WHERE prefix = ? AND ticket_id BETWEEN ? AND ? ORDER BY prefix, ticket_id", (prefix, r_start, r_end)) + results = self.cur.fetchall() + return [Ticket(*r) for r in results] + + def post_tickets(self, ts: list[Ticket]): + """Posts a range of tickets.""" + for t in ts: + self.cur.execute("""INSERT INTO tickets VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (prefix, ticket_id) + DO UPDATE SET first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, phone_number = EXCLUDED.phone_number, + pref = EXCLUDED.pref""", (t.prefix, t.ticket_id, t.first_name, t.last_name, t.phone_number, t.pref)) + self.conn.commit() + return {"detail": "Tickets posted successfully."} diff --git a/apiserver/src/data/routers.py b/apiserver/src/data/routers.py new file mode 100644 index 0000000..be9c0be --- /dev/null +++ b/apiserver/src/data/routers.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Header, status, HTTPException + +from .repos import PrefixRepo, TicketRepo +from .models import Prefix, Ticket +from ..core.auth import AuthRepo + +prefix_router = APIRouter(prefix="/api/prefix") + +inv_numbers_ex = HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="After last / has to be either an integer or range of integers (such as 1-20).") + +@prefix_router.get("") +def get_all_prefixes(tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return PrefixRepo().get_prefixes() + +@prefix_router.get("/{prefix}") +def get_one_prefix(prefix: str, tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return PrefixRepo().get_one_prefix(prefix) + +@prefix_router.post("") +def post_one_prefix(prefix: Prefix, tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return PrefixRepo().post_prefix(prefix) + +@prefix_router.delete("") +def del_one_prefix(prefix: str = "", tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return PrefixRepo().del_prefix(prefix) + +ticket_router = APIRouter(prefix="/api/tickets") + +@ticket_router.get("") +def get_all_tickets(tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return TicketRepo().get_all_tickets() + +@ticket_router.get("/{prefix}") +def get_prefix_tickets(prefix: str, tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return TicketRepo().get_prefix_tickets(prefix) + +@ticket_router.get("/{prefix}/{s_id}") +def get_ticket_scope(prefix: str, s_id: str, tam_auth_key: str = Header("")) -> Ticket | list[Ticket]: + AuthRepo().verify_key(tam_auth_key) + if "-" in s_id: + l_id = s_id.split("-", maxsplit=2) + try: + r_start, r_end = int(l_id[0]), int(l_id[1]) + except ValueError: + raise inv_numbers_ex + return TicketRepo().get_range_tickets(prefix, r_start, r_end) + else: + try: + i_id = int(s_id) + except ValueError: + raise inv_numbers_ex + return TicketRepo().get_one_ticket(prefix, i_id) + +@ticket_router.post("") +def post_tickets(ts: list[Ticket], tam_auth_key: str = Header("")): + AuthRepo().verify_key(tam_auth_key) + return TicketRepo().post_tickets(ts) diff --git a/apiserver/src/main.py b/apiserver/src/main.py new file mode 100644 index 0000000..2a76a14 --- /dev/null +++ b/apiserver/src/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI, Header + +from .core.auth import AuthRepo +from .core.db import init_db +from .core.settings import get_config +from .routers import append_routers + +config = get_config() + +init_db() + +app = FastAPI( + title="TAM Server", + docs_url=None if config["mode"] == "prod" else "/docs", + redoc_url=None if config["mode"] == "prod" else "/redoc", + openapi_url=None if config["mode"] == "prod" else "/openapi.json", +) + + +@app.get("/api") +def main_path(tam_auth_key: str = Header("")): + return { + "whoami": "TAM Server", + "authenticated": AuthRepo().check_key(tam_auth_key), + "status": "healthy", + } + + +append_routers(app) diff --git a/apiserver/src/routers.py b/apiserver/src/routers.py new file mode 100644 index 0000000..5c3b350 --- /dev/null +++ b/apiserver/src/routers.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +from .core.auth import auth_router +from .data import routers + +def append_routers(app: FastAPI): + app.include_router(auth_router) + app.include_router(routers.prefix_router) + app.include_router(routers.ticket_router)