initial commit

This commit is contained in:
Lucas Jensen
2024-05-01 09:19:01 -07:00
commit 5d67c0c2b2
117 changed files with 9917 additions and 0 deletions

15
server/.env.example Normal file
View File

@@ -0,0 +1,15 @@
[mysql]
DB_HOST=localhost
DB_USER=uresname
DB_PASSWORD=password
DB_DATABASE=databasename
[cloudinary]
CLOUDINARY_URL=cloudinary://yourkey:yoursecret@yourcloudname
[contact]
APP_PASSWORD=gmailpassword
EMAIL=destination@example.com
[oauth2-google]
AUDIENCE=some-value.apps.googleusercontent.com

36
server/.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: thegrapefruitsduo backend build
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v3
with:
python-version: "3.12"
- name: Install poetry
uses: abatilo/actions-poetry@v2
- name: Install dependencies
run: |
poetry install
- name: Lint with black
run: |
poetry run black --check .
- name: Test with pytest
run: |
poetry run pytest -s

176
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

1
server/.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

45
server/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Backend for The Grapefruits Duo
This is the backend for thegrapefruitsduo.com and is deployed at api.thegrapefruitsduo.com. It is a FastAPI app that serves information about the chamber music, the group's members, and upcoming events. It allows authorized users to modify most information. Data is persisted with MariaDB.
The general flow of this program is as follows, starting from the database layer:
- `python-mysql` is used to interact with the MariaDB database. This happens in `app/db`
- `app/controllers` contains the business logic for the app and consumes the database layer. Each controller is responsible for a different part of the app, with one main controller `app/controllers/controller.py` which imports, instantiates, and uses the other controllers.
- `app/routes` contains the FastAPI routes that consume the single controller. This controller is instantiated in `app/controllers/__init__.py` and passed to the routes.
No formal api specification is provided, but the routes are documented with FastAPI's Swagger UI at `/docs`.
## Basic Usage
Use of [poetry](https://python-poetry.org/docs/) is required. Creating the virtual environment with poetry is easy and should be done in the main project directory. `.venv` should be alongside `pyproject.toml`.
To install dependencies (venv will be created automatically if it doesn't exist):
```bash
poetry install
```
The following steps require proper environment variables to be set. An example can be found in `.env.example`
To seed the mysql database:
```bash
poetry run seed
```
To retrieve a token for testing:
```bash
poetry run token
```
To run the FastAPI app in development mode:
```bash
poetry run dev
```
### Deployment
This app is deployed on a Linode Ubuntu Server instance. NGINX is used as a reverse proxy and the app itself is managed by `systemd` and `uvicorn` as a service, and listens on port 6000. The app is served over HTTPS with a Let's Encrypt certificate.

1
server/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
from app.main import app

View File

@@ -0,0 +1,3 @@
from fastapi.security import HTTPBearer, OAuth2PasswordBearer
oauth2_http = HTTPBearer()

View File

@@ -0,0 +1,17 @@
import smtplib
from email.mime.text import MIMEText
from os import getenv
HOST = "grapefruitswebsite@gmail.com"
def send_email(subject: str, body: str) -> None:
password = getenv("APP_PASSWORD")
email = getenv("EMAIL")
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = HOST
smtp_server = smtplib.SMTP_SSL("smtp.gmail.com", 465)
smtp_server.login(HOST, password) # type: ignore
smtp_server.sendmail(HOST, [email], msg.as_string()) # type: ignore
smtp_server.quit()

View File

@@ -0,0 +1,48 @@
# Set your Cloudinary credentials
# ==============================
from pprint import pprint
from dotenv import load_dotenv
load_dotenv()
# Import the Cloudinary libraries
# ==============================
import cloudinary
import cloudinary.api
import cloudinary.uploader
# Set configuration parameter: return "https" URLs by setting secure=True
# ==============================
cloudinary.config(secure=True)
uploader = cloudinary.uploader
class CloudinaryException(Exception):
pass
def delete_image(public_id: str) -> None:
result = uploader.destroy(public_id)
if result.get("result") != "ok":
raise CloudinaryException("Failed to delete image")
def get_image_data(public_id: str) -> dict:
data = cloudinary.api.resource(public_id)
return data
def get_image_url(public_id: str) -> str:
url = cloudinary.utils.cloudinary_url(public_id)[0]
if url is None:
raise CloudinaryException("Failed to get image URL")
return url
if __name__ == "__main__":
image_id = "coco_copy_jywbxm"

View File

@@ -0,0 +1,27 @@
from os import getenv
from fastapi.security.http import HTTPAuthorizationCredentials
from google.auth import jwt
from icecream import ic
def _token_claims(token: HTTPAuthorizationCredentials) -> dict:
aud = getenv("AUDIENCE")
credentials = token.credentials
claims = jwt.decode(credentials, aud, verify=False)
if not claims:
raise ValueError("Invalid token")
if claims.get("aud") != aud:
raise ValueError("Invalid audience")
if claims.get("email_verified") is not True:
raise ValueError("Email not verified")
if not claims.get("email"):
raise ValueError("Email not found in token")
if not claims.get("sub"):
raise ValueError("Sub not found in token")
return claims
def email_and_sub(token: HTTPAuthorizationCredentials) -> tuple[str, str]:
claims = _token_claims(token)
return claims["email"], claims["sub"]

View File

@@ -0,0 +1,3 @@
from app.controllers.controller import Controller
controller = Controller()

View File

@@ -0,0 +1,28 @@
from fastapi import HTTPException, UploadFile, status
from app.db.base_queries import BaseQueries
ALLOWED_FILES_TYPES = ["image/jpeg", "image/png"]
MAX_FILE_SIZE = 1000000 # 1 MB
class BaseController:
def __init__(self) -> None:
self.db: BaseQueries = None # type: ignore
self.ALL_FILES = ALLOWED_FILES_TYPES
self.MAX_FILE_SIZE = MAX_FILE_SIZE
async def verify_image(self, file: UploadFile) -> bytes:
print("verifying image")
if file.content_type not in self.ALL_FILES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Allowed file types are {self.ALL_FILES}",
)
image_file = await file.read()
if len(image_file) > self.MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {len(image_file)} bytes exceeds maximum of {self.MAX_FILE_SIZE} bytes",
)
return image_file

View File

@@ -0,0 +1,99 @@
from fastapi import HTTPException, UploadFile, status
from fastapi.security import HTTPAuthorizationCredentials
from icecream import ic
from app.admin import oauth_token
from app.controllers.events import EventController
from app.controllers.group import GroupController
from app.controllers.musicians import MusicianController
from app.controllers.users import UserController
from app.models.event import EventSeries, NewEventSeries
from app.models.group import Group
from app.models.musician import Musician
from app.models.user import User
class Controller:
def __init__(self) -> None:
self.event_controller = EventController()
self.musician_controller = MusicianController()
self.user_controller = UserController()
self.group_controller = GroupController()
async def get_musicians(self) -> list[Musician]:
return await self.musician_controller.get_musicians()
async def get_musician(self, id: int) -> Musician:
return await self.musician_controller.get_musician(id)
async def update_musician(
self,
musician: Musician,
url_param_id: int,
token: HTTPAuthorizationCredentials,
file: UploadFile | None = None,
) -> Musician:
if musician.id != url_param_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in URL does not match ID in request body",
)
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.musician_controller.update_musician(
musician_id=musician.id,
new_bio=musician.bio,
file=file,
)
async def get_events(self) -> list[EventSeries]:
return await self.event_controller.get_all_series()
async def get_event(self, id: int) -> EventSeries:
return await self.event_controller.get_one_series(id)
async def create_event(
self, series: NewEventSeries, token: HTTPAuthorizationCredentials
) -> EventSeries:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.event_controller.create_series(series)
async def add_series_poster(
self, series_id: int, poster: UploadFile, token: HTTPAuthorizationCredentials
) -> EventSeries:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.event_controller.add_series_poster(series_id, poster)
async def delete_series(self, id: int, token: HTTPAuthorizationCredentials) -> None:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
await self.event_controller.delete_series(id)
async def update_series(
self, route_id: int, series: EventSeries, token: HTTPAuthorizationCredentials
) -> EventSeries:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.event_controller.update_series(route_id, series)
async def get_users(self) -> list[User]:
return await self.user_controller.get_users()
async def get_user(self, id: int) -> User:
return await self.user_controller.get_user_by_id(id)
async def create_user(self, token: HTTPAuthorizationCredentials) -> User:
return await self.user_controller.create_user(token)
async def get_group(self) -> Group:
return await self.group_controller.get_group()
async def update_group_bio(
self, bio: str, token: HTTPAuthorizationCredentials
) -> Group:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.group_controller.update_group_bio(bio)

View File

@@ -0,0 +1,106 @@
from fastapi import HTTPException, UploadFile, status
from icecream import ic
from mysql.connector.errors import IntegrityError
from app.admin.images import uploader
from app.controllers.base_controller import BaseController
from app.db import event_queries
from app.db.events import EventQueries
from app.models.event import Event, EventSeries, NewEventSeries
class EventController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: EventQueries = event_queries
def _all_series(self, data: list[dict]) -> list[EventSeries]:
all_series: dict[str, EventSeries] = {}
for event_series_row in data:
series_name: str = event_series_row["name"]
event = Event(**event_series_row)
if series_name not in all_series:
all_series[series_name] = EventSeries(**event_series_row, events=[])
all_series[series_name].events.append(event)
return [series for series in all_series.values()]
async def get_all_series(self) -> list[EventSeries]:
data = await self.db.get_all()
try:
return self._all_series(data)
except Exception as e:
ic(e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving event objects: {e}",
)
async def get_one_series(self, id: int) -> EventSeries:
if not (data := await self.db.get_one(id)):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Event not found"
)
try:
event = EventSeries(
**data[0], events=[Event(**e) for e in data if e.get("event_id")]
)
return event
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating event object: {e}",
)
async def create_series(self, series: NewEventSeries) -> EventSeries:
try:
inserted_id = await self.db.insert_one_series(series)
for new_event in series.events:
await self.db.insert_one_event(new_event, inserted_id)
return await self.get_one_series(inserted_id)
except IntegrityError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Series name already exists. Each series must have a unique name.\n{e}",
)
async def add_series_poster(self, series_id, poster: UploadFile) -> EventSeries:
series = await self.get_one_series(series_id)
series.poster_id = await self._upload_poster(poster)
await self.db.update_series_poster(series)
return await self.get_one_series(series.series_id)
async def _upload_poster(self, poster: UploadFile) -> str:
image_file = await self.verify_image(poster)
try:
data = uploader.upload(image_file)
return data.get("public_id")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error uploading image: {e}",
)
async def delete_series(self, id: int) -> None:
series = await self.get_one_series(id)
await self.db.delete_one_series(series)
async def update_series(self, route_id: int, series: EventSeries) -> EventSeries:
if route_id != series.series_id:
print("error")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in URL does not match ID in request body",
)
prev_series = await self.get_one_series(series.series_id)
if series.poster_id != prev_series.poster_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Poster ID cannot be updated directly. Use the /poster endpoint instead.",
)
await self.db.delete_events_by_series(series)
await self.db.replace_series(series)
for event in series.events:
await self.db.insert_one_event(event, series.series_id)
return await self.get_one_series(series.series_id)

View File

@@ -0,0 +1,35 @@
from fastapi import HTTPException, status
from app.controllers.base_controller import BaseController
from app.db import group_queries
from app.db.group import GroupQueries
from app.models.group import Group
class GroupController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: GroupQueries = group_queries
async def get_group(self) -> Group:
if (data := await self.db.get_one()) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
try:
return Group(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating group object: {e}",
)
async def update_group_bio(self, bio: str) -> Group:
try:
await self.db.update_group_bio(bio)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating group bio: {e}",
)
return await self.get_group()

View File

@@ -0,0 +1,89 @@
from fastapi import HTTPException, UploadFile, status
from icecream import ic
from app.admin.images import uploader
from app.controllers.base_controller import BaseController
from app.db import musician_queries
from app.db.musicians import MusicianQueries
from app.models.musician import Musician
class MusicianController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: MusicianQueries = musician_queries
async def get_musicians(self) -> list[Musician]:
data = await self.db.get_all()
try:
return [Musician(**m) for m in data]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating musician objects: {e}",
)
async def get_musician(self, id: int) -> Musician:
if (data := await self.db.get_one(id)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Musician not found"
)
try:
return Musician(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating musician object: {e}",
)
async def update_musician(
self,
musician_id: int,
new_bio: str,
file: UploadFile | None = None,
) -> Musician:
musician = await self.get_musician(musician_id)
if new_bio != musician.bio:
return await self.update_musician_bio(musician.id, new_bio)
if file is not None:
return await self.upload_headshot(musician.id, file)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Update operation not implemented. Neither the bio or headshot was updated.",
)
async def update_musician_headshot(self, id: int, headshot_id: str) -> Musician:
await self.get_musician(id)
try:
await self.db.update_headshot(id, headshot_id)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating musician headshot: {e}",
)
return await self.get_musician(id)
async def update_musician_bio(self, id: int, bio: str) -> Musician:
await self.get_musician(id) # Check if musician exists
try:
await self.db.update_bio(id, bio)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating musician bio: {e}",
)
return await self.get_musician(id)
async def upload_headshot(self, id: int, file: UploadFile) -> Musician:
image_file = await self.verify_image(file)
data = uploader.upload(image_file)
public_id = data.get("public_id")
if public_id is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload image",
)
await self.update_musician_headshot(id, public_id)
return await self.get_musician(id)

View File

@@ -0,0 +1,70 @@
from fastapi import HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials
from app.admin import oauth_token
from app.controllers.base_controller import BaseController
from app.db import user_queries
from app.db.users import UserQueries
from app.models.user import User
class UserController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: UserQueries = user_queries
async def get_users(self) -> list[User]:
data = await self.db.get_all()
try:
return [User(**e) for e in data]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user objects: {e}",
)
async def get_user_by_id(self, id: int) -> User:
if (data := await self.db.get_one(id)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
try:
return User(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user object: {e}",
)
async def get_user_by_email(self, email: str) -> User:
if (data := await self.db.get_one_by_email(email)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User does not exist"
)
try:
return User(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user object: {e}",
)
async def get_user_by_sub(self, sub: str) -> User:
if (data := await self.db.get_one_by_sub(sub)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
try:
return User(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user object: {e}",
)
async def create_user(self, token: HTTPAuthorizationCredentials) -> User:
email, sub = oauth_token.email_and_sub(token)
user: User = await self.get_user_by_email(email)
if user.sub is None:
await self.db.update_sub(user.email, sub)
return await self.get_user_by_sub(sub)

View File

@@ -0,0 +1,9 @@
from .events import EventQueries
from .group import GroupQueries
from .musicians import MusicianQueries
from .users import UserQueries
event_queries = EventQueries()
user_queries = UserQueries()
musician_queries = MusicianQueries()
group_queries = GroupQueries()

View File

@@ -0,0 +1,32 @@
from typing import Callable
from app.db.conn import connect_db
class BaseQueries:
from icecream import ic
def __init__(self) -> None:
self.table: str = None # type: ignore
self.connect_db: Callable = connect_db
async def get_all(self) -> list[dict]:
query = f"SELECT * FROM {self.table}"
db = connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query)
data = cursor.fetchall()
cursor.close()
db.close()
return data # type: ignore
async def get_one(self, id: int) -> dict | None:
query = f"SELECT * FROM {self.table} WHERE id = %s"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (id,))
data = cursor.fetchone()
cursor.close()
db.close()
return data # type: ignore

31
server/app/db/conn.py Normal file
View File

@@ -0,0 +1,31 @@
import os
import mysql.connector
from dotenv import load_dotenv
class DBException(Exception):
pass
def connect_db() -> mysql.connector.MySQLConnection:
load_dotenv()
host = os.getenv("DB_HOST")
user = os.getenv("DB_USER")
password = os.getenv("DB_PASSWORD")
database = os.getenv("DB_DATABASE")
if None in [host, user, password, database]:
raise DBException("Missing database credentials")
try:
return mysql.connector.connect(
host=host,
user=user,
password=password,
database=database,
auth_plugin="mysql_native_password",
) # type: ignore
except mysql.connector.Error as err:
raise DBException("Could not connect to database") from err

140
server/app/db/events.py Normal file
View File

@@ -0,0 +1,140 @@
from asyncio import gather
from icecream import ic
from app.db.base_queries import BaseQueries
from app.models.event import (
EVENT_TABLE,
SERIES_TABLE,
Event,
EventSeries,
NewEvent,
NewEventSeries,
)
class EventQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = SERIES_TABLE
async def get_one(self, series_id: int) -> list[dict] | None:
query = f"""
SELECT *
FROM {SERIES_TABLE}
INNER JOIN {EVENT_TABLE}
ON {SERIES_TABLE}.series_id = {EVENT_TABLE}.series_id
WHERE {SERIES_TABLE}.series_id = %s
"""
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (series_id,))
data = cursor.fetchall()
cursor.close()
db.close()
return data
async def get_all(self) -> list[dict]:
query = f"""
SELECT *
FROM {SERIES_TABLE}
INNER JOIN {EVENT_TABLE}
ON {SERIES_TABLE}.series_id = {EVENT_TABLE}.series_id
"""
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query)
data = cursor.fetchall()
cursor.close()
db.close()
return data
async def insert_one_series(self, series: NewEventSeries) -> int:
query = f"INSERT INTO {self.table} (name, description) VALUES (%s, %s)"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(
query,
(
series.name,
series.description,
),
)
inserted_id = cursor.lastrowid
db.commit()
cursor.close()
db.close()
return inserted_id
async def insert_one_event(self, event: NewEvent, series_id: int) -> int:
query = f"INSERT INTO {EVENT_TABLE} (series_id, location, time, ticket_url, map_url) VALUES (%s, %s, %s, %s, %s)"
db = self.connect_db()
cursor = db.cursor()
ticket_url = str(event.ticket_url) if event.ticket_url else None
map_url = str(event.map_url) if event.map_url else None
cursor.execute(
query, (series_id, event.location, event.time, ticket_url, map_url)
)
iserted_id = cursor.lastrowid
db.commit()
cursor.close()
db.close()
return iserted_id
async def delete_events_by_series(self, series: EventSeries) -> None:
query = f"DELETE FROM {EVENT_TABLE} WHERE series_id = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (series.series_id,))
db.commit()
cursor.close()
async def delete_one_series(self, series: EventSeries) -> None:
query = f"DELETE FROM {self.table} WHERE series_id = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (series.series_id,))
db.commit()
cursor.close()
async def update_series_poster(self, series: EventSeries) -> None:
query = f"UPDATE {self.table} SET poster_id = %s WHERE series_id = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (series.poster_id, series.series_id))
db.commit()
cursor.close()
async def replace_event(self, event: Event) -> None:
query = f"""
UPDATE {EVENT_TABLE}
SET location = %s, time = %s, ticket_url = %s, map_url = %s
WHERE event_id = %s
"""
db = self.connect_db()
cursor = db.cursor()
ticket_url = str(event.ticket_url) if event.ticket_url else None
map_url = str(event.map_url) if event.map_url else None
cursor.execute(
query, (event.location, event.time, ticket_url, map_url, event.event_id)
)
db.commit()
cursor.close()
db.close()
async def replace_series(self, series: EventSeries) -> None:
query = f"""
UPDATE {self.table}
SET name = %s, description = %s, poster_id = %s
WHERE series_id = %s
"""
db = self.connect_db()
cursor = db.cursor()
cursor.execute(
query, (series.name, series.description, series.poster_id, series.series_id)
)
db.commit()
cursor.close()
db.close()

36
server/app/db/group.py Normal file
View File

@@ -0,0 +1,36 @@
from app.db.base_queries import BaseQueries
from app.models.group import GROUP_TABLE
class GroupQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = GROUP_TABLE
async def get_one(self) -> dict:
query = f"SELECT * FROM {self.table}"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query)
data = cursor.fetchone()
cursor.close()
db.close()
if not data:
raise Exception("error retrieving group")
return data
async def get_all(self) -> None:
raise NotImplementedError(
"get_all method not implemented for GroupQueries. There's only one row in the table."
)
async def update_group_bio(self, bio: str) -> None:
db = self.connect_db()
cursor = db.cursor()
query = f"UPDATE {self.table} SET bio = %s WHERE id = 1" # only one row in the table
cursor.execute(query, (bio,))
db.commit()
cursor.close()
db.close()

View File

@@ -0,0 +1,29 @@
from icecream import ic
from app.db.base_queries import BaseQueries
from app.db.conn import connect_db
from app.models.musician import MUSICIAN_TABLE
class MusicianQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = MUSICIAN_TABLE
async def update_bio(self, id: int, bio: str) -> None:
db = connect_db()
cursor = db.cursor()
query = f"UPDATE {self.table} SET bio = %s WHERE id = %s"
cursor.execute(query, (bio, id))
db.commit()
cursor.close()
db.close()
async def update_headshot(self, id: int, headshot_id: str) -> None:
db = connect_db()
cursor = db.cursor()
query = f"UPDATE {self.table} SET headshot_id = %s WHERE id = %s"
cursor.execute(query, (headshot_id, id))
db.commit()
cursor.close()
db.close()

42
server/app/db/users.py Normal file
View File

@@ -0,0 +1,42 @@
from app.db.base_queries import BaseQueries
from app.models.user import USER_TABLE
class UserQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = USER_TABLE
async def get_one_by_email(self, email: str) -> dict | None:
query = f"SELECT * FROM {self.table} WHERE email = %s"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (email,))
data = cursor.fetchone()
cursor.close()
db.close()
return data
async def get_one_by_sub(self, sub: str) -> dict | None:
query = f"SELECT * FROM {self.table} WHERE sub = %s"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (sub,))
data = cursor.fetchone()
cursor.close()
db.close()
if not data:
return None
return data
async def update_sub(self, email: str, sub: str) -> None:
query = f"UPDATE {self.table} SET sub = %s WHERE email = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (sub, email))
db.commit()
cursor.close()
db.close()

56
server/app/main.py Normal file
View File

@@ -0,0 +1,56 @@
from asyncio import gather
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.controllers import Controller
from app.models.tgd import TheGrapefruitsDuo
from app.routers.contact import router as contact_router
from app.routers.events import router as event_router
from app.routers.group import router as group_router
from app.routers.musicians import router as musician_router
from app.routers.users import router as user_router
from app.scripts.version import get_version
app = FastAPI(
title="The Grapefruits Duo API",
description="API for The Grapefruits Duo website",
version=get_version(),
)
app.include_router(musician_router)
app.include_router(group_router)
app.include_router(contact_router)
app.include_router(event_router)
app.include_router(user_router)
controller = Controller()
origins = [
"http://localhost:3000",
"https://thegrapefruitsduo.com",
"https://www.thegrapefruitsduo.com",
"https://tgd.lucasjensen.me",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/", tags=["root"])
async def root() -> TheGrapefruitsDuo:
musicians, events, group = await gather(
controller.get_musicians(),
controller.get_events(),
controller.get_group(),
)
return TheGrapefruitsDuo(
version=get_version(),
group=group,
musicians=musicians,
events=events,
)

View File

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel, EmailStr
class Contact(BaseModel):
name: str
email: EmailStr
message: str

View File

@@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
from fastapi import UploadFile
from pydantic import BaseModel, HttpUrl
class Poster(BaseModel):
file: UploadFile
class NewEvent(BaseModel):
location: str
time: datetime
map_url: Optional[HttpUrl] = None
ticket_url: Optional[HttpUrl] = None
class Event(NewEvent):
event_id: int
class NewEventSeries(BaseModel):
name: str
description: str
events: list[NewEvent]
class EventSeries(NewEventSeries):
series_id: int
events: list[Event]
poster_id: Optional[str] = None
SERIES_TABLE = "series"
EVENT_TABLE = "events"

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class Group(BaseModel):
name: str
bio: str
id: int | None = None
GROUP_TABLE = "group_table"

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel, Field
class NewMusician(BaseModel):
name: str
bio: str
headshot_id: str # cloudinary id
class Musician(NewMusician):
id: int
MUSICIAN_TABLE = "musicians"

12
server/app/models/tgd.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
from app.models.event import EventSeries
from app.models.group import Group
from app.models.musician import Musician
class TheGrapefruitsDuo(BaseModel):
version: str
group: Group
musicians: list[Musician]
events: list[EventSeries]

13
server/app/models/user.py Normal file
View File

@@ -0,0 +1,13 @@
from typing import Optional
from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
sub: Optional[str] = None
id: int | None = None
USER_TABLE = "users"

View File

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, status
from app.admin.contact import send_email
from app.models.contact import Contact
router = APIRouter(
prefix="/contact",
tags=["contact"],
responses={404: {"description": "Not found"}},
)
@router.post("/", status_code=status.HTTP_201_CREATED)
async def post_message(contact: Contact):
"""Sends an email to the site owner with the provided name, email, and message."""
subject = f"New message from {contact.name}"
body = f"From: {contact.email}\n\n{contact.message}"
send_email(subject, body)

View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.security import HTTPAuthorizationCredentials
from icecream import ic
from app.admin import oauth2_http
from app.controllers import controller
from app.models.event import EventSeries, NewEventSeries
router = APIRouter(
prefix="/events",
tags=["events"],
responses={404: {"description": "Not found"}},
)
@router.get("/")
async def get_events() -> list[EventSeries]:
return await controller.get_events()
@router.get("/{id}")
async def get_event(id: int) -> EventSeries:
return await controller.get_event(id)
@router.post("/")
async def create_series(
series: NewEventSeries,
token: HTTPAuthorizationCredentials = Depends(oauth2_http),
) -> EventSeries:
return await controller.create_event(series, token)
@router.delete("/{id}")
async def delete_event(
id: int, token: HTTPAuthorizationCredentials = Depends(oauth2_http)
) -> None:
await controller.delete_series(id, token)
@router.post("/{id}/poster")
async def add_series_poster(
id: int,
poster: UploadFile = File(...),
token: HTTPAuthorizationCredentials = Depends(oauth2_http),
) -> EventSeries:
return await controller.add_series_poster(id, poster, token)
@router.put("/{id}")
async def update_event(
id: int,
event: EventSeries,
token: HTTPAuthorizationCredentials = Depends(oauth2_http),
) -> EventSeries:
return await controller.update_series(id, event, token)

View File

@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, status
from fastapi.security.http import HTTPAuthorizationCredentials
from icecream import ic
from app.admin import oauth2_http
from app.controllers import controller
from app.models.group import Group
router = APIRouter(
prefix="/group",
tags=["group"],
responses={404: {"description": "Not found"}},
)
@router.get("/", status_code=status.HTTP_200_OK)
async def get_group() -> Group:
return await controller.get_group()
@router.patch("/")
async def update_group(
group: Group, token: HTTPAuthorizationCredentials = Depends(oauth2_http)
) -> Group:
"""Updates the group bio, but requires the entire group object to be sent in the request body.
Requires authentication."""
return await controller.update_group_bio(group.bio, token)

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, UploadFile, status
from fastapi.security import HTTPAuthorizationCredentials
from icecream import ic
from app.admin import oauth2_http
from app.controllers import controller
from app.models.musician import Musician
router = APIRouter(
prefix="/musicians",
tags=["musicians"],
responses={404: {"description": "Not found"}},
)
@router.get("/", status_code=status.HTTP_200_OK)
async def get_musicians() -> list[Musician]:
return await controller.get_musicians()
@router.get("/{id}", status_code=status.HTTP_200_OK)
async def get_musician(id: int) -> Musician:
return await controller.get_musician(id)
@router.patch("/{id}")
async def update_musician(
id: int,
musician: Musician,
token: HTTPAuthorizationCredentials = Depends(oauth2_http),
) -> Musician:
"""Updates a musician's bio, but requires the entire musician object to be sent in the request body.
Requires authentication."""
return await controller.update_musician(
musician=musician, url_param_id=id, token=token
)
@router.post("/{id}/headshot", status_code=status.HTTP_200_OK)
async def update_musician_headshot(
id: int,
file: UploadFile,
token: HTTPAuthorizationCredentials = Depends(oauth2_http),
) -> Musician | None:
"""Recieves a headshot image file, uploads it to cloudinary, and updates the musician's headshot url in the database"""
musician = await controller.get_musician(id)
return await controller.update_musician(
musician=musician, url_param_id=id, token=token, file=file
)

View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from app.admin import oauth2_http
from app.controllers import controller
from app.models.user import User
router = APIRouter(
prefix="/users",
tags=["users"],
responses={404: {"description": "Not found"}},
)
@router.get("/", status_code=status.HTTP_200_OK)
async def get_users() -> list[User]:
return await controller.get_users()
@router.get("/{id}", status_code=status.HTTP_200_OK)
async def get_user(id: int) -> User:
return await controller.get_user(id)
@router.post("/", status_code=status.HTTP_200_OK)
async def create_user(
token: HTTPAuthorizationCredentials = Depends(oauth2_http),
) -> User | None:
return await controller.create_user(token)

View File

@@ -0,0 +1,62 @@
/*
This file is autogenerated by DBeaver.
Paste updated definitions below as changes are made to the database schema.
*/
-- thegrapefruitsduo.group_table definition
CREATE TABLE `group_table` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`bio` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- thegrapefruitsduo.musicians definition
CREATE TABLE `musicians` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`bio` text NOT NULL,
`headshot_id` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- thegrapefruitsduo.series definition
CREATE TABLE `series` (
`series_id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`description` text NOT NULL,
`poster_id` varchar(255) DEFAULT NULL,
PRIMARY KEY (`series_id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- thegrapefruitsduo.users definition
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`sub` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- thegrapefruitsduo.events definition
CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT,
`series_id` int(11) NOT NULL,
`location` varchar(255) NOT NULL,
`time` datetime NOT NULL,
`ticket_url` varchar(255) DEFAULT NULL,
`map_url` varchar(255) DEFAULT NULL,
PRIMARY KEY (`event_id`),
KEY `series_id` (`series_id`),
CONSTRAINT `events_ibfk_1` FOREIGN KEY (`series_id`) REFERENCES `series` (`series_id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

11
server/app/scripts/run.py Normal file
View File

@@ -0,0 +1,11 @@
import subprocess
from pathlib import Path
def main() -> None:
curr_dir = Path(__file__).resolve().parent.absolute()
script = curr_dir / "run.sh"
try:
subprocess.run(["sh", script], check=True)
except KeyboardInterrupt:
return

3
server/app/scripts/run.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
uvicorn app:app --reload --port 8000

266
server/app/scripts/seed.py Normal file
View File

@@ -0,0 +1,266 @@
from datetime import datetime
from dotenv import load_dotenv
from app.db.conn import connect_db
from app.models.event import EVENT_TABLE, SERIES_TABLE, Event, EventSeries
from app.models.group import GROUP_TABLE, Group
from app.models.musician import MUSICIAN_TABLE, NewMusician
from app.models.user import USER_TABLE, User
margarite: NewMusician = NewMusician(
name="Margarite Waddell",
bio="French hornist Margarite Waddell holds positions with the Eugene Symphony, Sarasota Opera, Boise Philharmonic, Rogue Valley Symphony, and Newport Symphony. As a freelancer, Margarite has played with ensembles throughout the West Coast including the Oregon Symphony, Portland Opera, Santa Rosa Symphony, Marin Symphony, and Symphony San Jose. She has performed with popular artists such as The Who, Josh Groban, and Sarah Brightman. Margarite can be heard on Kamyar Mohajers album “Pictures of the Hidden” on Navona Records. She appeared as a soloist with the Silicon Valley Philharmonic in 2016. Margarite cares deeply about music education and has taught private lessons, sectionals, and masterclasses throughout the Bay Area, Southwestern Oregon, Eugene, and Corvallis since 2013. She also performed in the San Francisco Symphony's Adventures in Music program for the 2016-2017 season. Margarite received her bachelors degree from the University of Oregon, and her masters degree from the San Francisco Conservatory of Music.",
headshot_id="zlpkcrvbdsicgj7qtslx",
)
coco: NewMusician = NewMusician(
name="Coco Bender",
bio="Coco Bender is a pianist residing in the Pacific Northwest. She recently performed with Cascadia Composers, recorded original film scores by Portland composer Christina Rusnak for the Pioneers: First Woman Filmmakers Project, and during the pandemic presented a series of outdoor recitals featuring music by H. Leslie Adams, William Grant Still, Bartok, and others. Coco is a founding member of the Eugene based horn and piano duo, The Grapefruits, as well as a co-artistic director and musical director of an all-women circus, Girl Circus. She has taken master classes with Inna Faliks, Tamara Stefanovich, and Dr. William Chapman Nyaho. Coco currently studies with Dr. Thomas Otten. In addition to performing regularly, she teaches a large studio of students in the Pacific Northwest, from Seattle WA to Eugene OR. Coco was the accompanist for Portland treble choir Aurora Chorus, during their 2021-2022, season under the conductorship of Kathleen Hollingsworth, Margaret Green, Betty Busch, and Joan Szymko.",
headshot_id="coco_copy_jywbxm",
)
coco_user: User = User(
name="Coco Bender",
email="cocobender.piano@gmail.com",
)
margarite_user: User = User(
name="Margarite Waddell",
email="mgwaddell@gmail.com",
)
lucas_user: User = User(name="Lucas Jensen", email="lucas.p.jensen10@gmail.com")
tgd_user: User = User(
name="The Grapefruits Duo",
email="thegrapefruitsduo@gmail.com",
)
tgd_website: User = User(
name="The Grapefruits Duo Website", email="grapefruitswebsite@gmail.com"
)
tgd: Group = Group(
bio="The Grapefruits, comprising of Coco Bender, piano, and Margarite Waddell, french horn, are a contemporary classical music duo. They perform frequently through out the PNW with the goal presenting traditional classical french horn repertoire, new 20th century works, and commissioned works by PNW composers.",
name="The Grapefruits Duo",
)
series1 = EventSeries(
name="The Grapefruits Duo Presents: Works for Horn and Piano",
description="Pieces by Danzi, Gomez, Gounod, Grant, and Rusnak!",
poster_id="The_Grapefruits_Present_qhng6y",
events=[
Event(
location="Medford, OR",
time=datetime(2024, 5, 31, 19),
event_id=0,
),
Event(
location="First Presbyterian Church Newport",
time=datetime(2024, 6, 16, 16),
event_id=0,
map_url="https://maps.app.goo.gl/hNfN8X5FBZLg8LDF8", # type: ignore
),
Event(
location="First Church of Christ, Scientist, Eugene",
time=datetime(2024, 6, 23, 15),
event_id=0,
),
],
series_id=0,
)
# series2 = EventSeries(
# name="The Grapefruits Duo Features: Solos for Bass Trombone",
# description="Pieces by Ewazen, Bozza, and more!",
# events=[
# Event(
# location="Eugene Family YMCA",
# time=datetime(2024, 7, 1, 17, 30),
# event_id=0,
# ),
# Event(
# location="Tobi's Crate",
# time=datetime(2024, 7, 2, 20),
# event_id=0,
# ticket_url="http://www.example.com", # type: ignore
# ),
# ],
# id=0,
# )
def seed():
confirmation = input(
"Are you sure you want to seed the database? Data will be lost. [Y/n]: "
)
if confirmation.lower() not in ["y", "yes", ""]:
print("Exiting")
return
print("Seeding database")
add_musicians()
add_users()
add_group()
add_events()
def add_group():
print("Adding group")
db = connect_db()
cursor = db.cursor()
cursor.execute(
f"DROP TABLE IF EXISTS {GROUP_TABLE};",
)
cursor.execute(
f"""
CREATE TABLE {GROUP_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
bio TEXT NOT NULL,
PRIMARY KEY (id)
);
"""
)
cursor.execute(
f"INSERT INTO {GROUP_TABLE} (name, bio) VALUES (%s, %s);",
(tgd.name, tgd.bio),
)
db.commit()
cursor.close()
def add_users():
print("Adding users")
db = connect_db()
cursor = db.cursor()
cursor.execute(
f"DROP TABLE IF EXISTS {USER_TABLE};",
)
cursor.execute(
f"""
CREATE TABLE {USER_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
sub VARCHAR(255),
PRIMARY KEY (id)
);
"""
)
for u in [coco_user, margarite_user, lucas_user, tgd_user, tgd_website]:
cursor.execute(
f"INSERT INTO {USER_TABLE} (name, email, sub) VALUES (%s, %s, %s);",
(u.name, u.email, u.sub),
)
db.commit()
cursor.close()
def add_musicians():
print("Adding musicians")
db = connect_db()
cursor = db.cursor()
cursor.execute(
f"DROP TABLE IF EXISTS {MUSICIAN_TABLE};",
)
cursor.execute(
f"""
CREATE TABLE {MUSICIAN_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
bio TEXT NOT NULL,
headshot_id VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);
"""
)
for m in [margarite, coco]:
cursor.execute(
f"INSERT INTO {MUSICIAN_TABLE} (name, bio, headshot_id) VALUES (%s, %s, %s);",
(m.name, m.bio, m.headshot_id),
)
db.commit()
cursor.close()
def add_events():
print("Adding events")
db = connect_db()
cursor = db.cursor()
cursor.execute(
f"DROP TABLE IF EXISTS {EVENT_TABLE};",
)
cursor.execute(
f"DROP TABLE IF EXISTS {SERIES_TABLE};",
)
cursor.execute(
f"""
CREATE TABLE {SERIES_TABLE} (
series_id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
poster_id VARCHAR(255),
PRIMARY KEY (series_id)
);
"""
)
cursor.execute(
f"""
CREATE TABLE {EVENT_TABLE} (
event_id INT NOT NULL AUTO_INCREMENT,
series_id INT NOT NULL,
location VARCHAR(255) NOT NULL,
time DATETIME NOT NULL,
ticket_url VARCHAR(255),
map_url VARCHAR(255),
PRIMARY KEY (event_id),
FOREIGN KEY (series_id) REFERENCES {SERIES_TABLE}(series_id) ON DELETE CASCADE
);
"""
)
for series in [series1]:
cursor.execute(
f"INSERT INTO {SERIES_TABLE} (name, description, poster_id) VALUES (%s, %s, %s);",
(
series.name,
series.description,
series.poster_id,
),
)
series_id = cursor.lastrowid
if series_id is None:
raise Exception("Error inserting series: could not get last row id.")
series.series_id = series_id
for event in series.events:
ticket_url = str(event.ticket_url) if event.ticket_url else None
map_url = str(event.map_url) if event.map_url else None
cursor.execute(
f"INSERT INTO {EVENT_TABLE} (series_id, location, time, ticket_url, map_url) VALUES (%s, %s, %s, %s, %s);",
(
series.series_id,
event.location,
event.time,
ticket_url,
map_url,
),
)
db.commit()
cursor.close()
def main():
load_dotenv()
seed()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,18 @@
import toml
from pathlib import Path
def get_version() -> str:
abs_path = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
try:
with open(abs_path) as file:
data = toml.load(file)
return data["tool"]["poetry"]["version"]
except Exception as e:
return "0.0.0"
finally:
file.close()
if __name__ == "__main__":
print(get_version())

1390
server/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

37
server/pyproject.toml Normal file
View File

@@ -0,0 +1,37 @@
[tool.poetry]
name = "thegrapefruitsduo"
version = "0.3.1"
package-mode = false
description = "FastAPI backend for thegrapefruitsduo.com"
authors = ["Lucas Jensen <lucas.p.jensen10@gmail.com>"]
readme = "README.md"
packages = [{ include = "app" }]
[tool.poetry.dependencies]
python = "^3.12"
fastapi = { extras = ["all"], version = "^0.110.0" }
python-dotenv = "^1.0.1"
icecream = "^2.1.3"
mysql-connector-python = "^8.3.0"
cloudinary = "^1.39.1"
toml = "^0.10.2"
pyperclip = "^1.8.2"
google-auth = "^2.29.0"
[tool.poetry.dev-dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.23.6"
pytest = "^8.1.1"
[tool.poetry.scripts]
dev = "app.scripts.run:main"
seed = "app.scripts.seed:main"
token = "app.scripts.token:main"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

0
server/tests/__init__.py Normal file
View File

9
server/tests/test_app.py Normal file
View File

@@ -0,0 +1,9 @@
from fastapi.testclient import TestClient
from app import app
client = TestClient(app=app)
def test_app():
assert isinstance(client, TestClient)