tons of fun

This commit is contained in:
2025-06-04 01:40:12 -05:00
parent e8acd6bb20
commit c885ede8af
18 changed files with 206 additions and 209 deletions

View File

@ -13,3 +13,7 @@ MYSQL_USER=plant_user
MYSQL_PASSWORD=plant_pass MYSQL_PASSWORD=plant_pass
MYSQL_ROOT_PASSWORD=supersecret MYSQL_ROOT_PASSWORD=supersecret
NEO4J_URI=bolt://neo4j:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=your_secure_password

View File

@ -24,3 +24,7 @@ class Config:
# Optional toggles # Optional toggles
ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1' ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1'
DOCKER_ENV = os.environ.get('FLASK_ENV', 'production') DOCKER_ENV = os.environ.get('FLASK_ENV', 'production')
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687')
NEO4J_USER = os.getenv('NEO4J_USER', 'neo4j')
NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD', 'your_secure_password')

32
app/neo4j_utils.py Normal file
View File

@ -0,0 +1,32 @@
from neo4j import GraphDatabase
from flask import current_app
class Neo4jHandler:
def __init__(self, uri, user, password):
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
self.driver.close()
def create_plant_node(self, uuid, name):
with self.driver.session() as session:
session.run(
"MERGE (p:Plant {uuid: $uuid}) "
"SET p.name = $name",
uuid=uuid, name=name
)
def create_lineage(self, child_uuid, parent_uuid):
with self.driver.session() as session:
session.run(
"MATCH (child:Plant {uuid: $child_uuid}), (parent:Plant {uuid: $parent_uuid}) "
"MERGE (parent)-[:PARENT_OF]->(child)",
child_uuid=child_uuid, parent_uuid=parent_uuid
)
def get_neo4j_handler():
uri = current_app.config['NEO4J_URI']
user = current_app.config['NEO4J_USER']
password = current_app.config['NEO4J_PASSWORD']
return Neo4jHandler(uri, user, password)

View File

@ -54,5 +54,17 @@ services:
depends_on: depends_on:
- db - db
neo4j:
image: neo4j:5.18
container_name: nip_neo4j
ports:
- '7474:7474'
- '7687:7687'
environment:
- NEO4J_AUTH=neo4j/your_secure_password
volumes:
- neo4j_data:/data
volumes: volumes:
plant_price_tracker_mysql_data: plant_price_tracker_mysql_data:
neo4j_data:

BIN
main.zip

Binary file not shown.

View File

@ -5,6 +5,7 @@ from app import db
class User(db.Model, UserMixin): class User(db.Model, UserMixin):
__tablename__ = 'users' __tablename__ = 'users'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)

View File

@ -31,7 +31,7 @@
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('importer.upload_csv') }}">Import</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('importer.upload') }}">Import</a></li>
{% if current_user.is_authenticated and current_user.role == 'admin' %} {% if current_user.is_authenticated and current_user.role == 'admin' %}
<li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a></li> <li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a></li>
{% endif %} {% endif %}

View File

@ -1,12 +1,14 @@
from datetime import datetime from datetime import datetime
from app import db from app import db
from plugins.plant.models import Plant
class GrowLog(db.Model): class GrowLog(db.Model):
__tablename__ = 'grow_logs' __tablename__ = 'grow_logs'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False) plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
title = db.Column(db.String(255), nullable=False) title = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@ -18,7 +20,7 @@ class PlantUpdate(db.Model):
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False) plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True) growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True)
update_type = db.Column(db.String(50), nullable=False) update_type = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)

View File

@ -0,0 +1,2 @@
def register_commands(app):
pass

View File

@ -1,174 +1,77 @@
# plugins/importer/routes.py
import csv import csv
import io import io
import re from flask import Blueprint, request, render_template, redirect, flash
import uuid from werkzeug.utils import secure_filename
from app.neo4j_utils import get_neo4j_handler
from plugins.plant.models import db, Plant, PlantCommon, PlantScientific
from flask import ( bp = Blueprint("importer", __name__, template_folder="templates")
Blueprint, flash, redirect, render_template, request, url_for
)
from flask_login import login_required, current_user
from sqlalchemy.exc import SQLAlchemyError
from app import db
from plugins.plant.models import (
Plant, PlantCommonName, PlantScientificName, PlantLineage
)
# Blueprint setup
bp = Blueprint(
'importer',
__name__,
template_folder='templates',
url_prefix='/import'
)
# Expected CSV headers (exact, in this order)
EXPECTED_HEADERS = ['uuid', 'plant_type', 'name', 'scientific_name', 'mother_uuid']
# Strict regex for UUIDv4
UUID_REGEX = re.compile(
r'^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$'
)
def is_valid_uuid(u: str) -> bool:
"""Return True if 'u' matches UUIDv4 format exactly."""
return bool(UUID_REGEX.match(u))
def sanitize_field(field_value: str) -> str:
"""
Prevent CSV-injection by prefixing any cell that starts with
'=', '+', '-', or '@' with a space.
"""
if isinstance(field_value, str) and field_value and field_value[0] in ['=', '+', '-', '@']:
return ' ' + field_value
return field_value
def validate_row(row: dict, line_number: int):
"""
Validate a single CSV row. Returns (cleaned_row_dict, errors_list).
If errors_list is not empty, the row is invalid.
"""
errors = []
cleaned = {}
# 1) uuid: if provided, must be valid; otherwise generate a new one
raw_uuid = row.get('uuid', '').strip()
if raw_uuid:
if not is_valid_uuid(raw_uuid):
errors.append(f"Line {line_number}: Invalid UUID format '{raw_uuid}'.")
else:
cleaned['uuid'] = raw_uuid
else:
# auto-generate
cleaned['uuid'] = str(uuid.uuid4())
# 2) plant_type: required, <= 50 chars
plant_type = row.get('plant_type', '').strip()
if not plant_type:
errors.append(f"Line {line_number}: 'plant_type' is required.")
elif len(plant_type) > 50:
errors.append(f"Line {line_number}: 'plant_type' exceeds 50 characters.")
else:
cleaned['plant_type'] = sanitize_field(plant_type)
# 3) name: required, <= 100 chars
name = row.get('name', '').strip()
if not name:
errors.append(f"Line {line_number}: 'name' (common name) is required.")
elif len(name) > 100:
errors.append(f"Line {line_number}: 'name' exceeds 100 characters.")
else:
cleaned['name'] = sanitize_field(name)
# 4) scientific_name: required, <= 100 chars
sci = row.get('scientific_name', '').strip()
if not sci:
errors.append(f"Line {line_number}: 'scientific_name' is required.")
elif len(sci) > 100:
errors.append(f"Line {line_number}: 'scientific_name' exceeds 100 characters.")
else:
cleaned['scientific_name'] = sanitize_field(sci)
# 5) mother_uuid: optional. If present (and not 'N/A'), must be valid
raw_mother = row.get('mother_uuid', '').strip()
if raw_mother and raw_mother.upper() != 'N/A':
if not is_valid_uuid(raw_mother):
errors.append(f"Line {line_number}: 'mother_uuid' has invalid UUID '{raw_mother}'.")
else:
cleaned['mother_uuid'] = raw_mother
else:
cleaned['mother_uuid'] = None
if errors:
return None, errors
return cleaned, None
@bp.route("/", methods=["GET", "POST"])
@login_required
def upload_csv():
headers = [
"name", "scientific_name", "type", "status", "description"
]
@bp.route("/import/", methods=["GET", "POST"])
def upload():
if request.method == "POST": if request.method == "POST":
file = request.files.get("file") file = request.files.get("file")
if not file or not file.filename.endswith(".csv"): if not file:
flash("Please upload a valid CSV file.", "danger") flash("No file uploaded.", "error")
return redirect(request.url) return redirect(request.url)
try: try:
stream = io.StringIO(file.stream.read().decode("UTF-8")) # Handle UTF-8 BOM (Byte Order Mark) if present
csv_reader = csv.reader(stream) decoded = file.read().decode("utf-8-sig")
data = list(csv_reader) stream = io.StringIO(decoded)
reader = csv.DictReader(stream)
if not data or data[0] != headers: neo = get_neo4j_handler()
flash("CSV headers must exactly match: " + ", ".join(headers), "danger")
return redirect(request.url)
# Skip header row for row in reader:
rows = data[1:] uuid = row.get("uuid")
name = row.get("name")
sci_name = row.get("scientific_name")
plant_type = row.get("plant_type", "plant")
mother_uuid = row.get("mother_uuid")
with db.session.begin_nested(): # rollback safe transaction if not all([uuid, name, sci_name]):
for row in rows: continue # skip incomplete rows
name, sci_name, plant_type, plant_status, desc = row
# Get or create scientific name entry # Common Name
sci = PlantScientificName.query.filter_by(name=sci_name.strip()).first() common = PlantCommon.query.filter_by(name=name).first()
if not sci: if not common:
sci = PlantScientificName(name=sci_name.strip()) common = PlantCommon(name=name)
db.session.add(sci) db.session.add(common)
db.session.flush() # ensure sci.id is available db.session.flush()
# Create plant entry # Scientific Name
new_plant = Plant( scientific = PlantScientific.query.filter_by(name=sci_name).first()
uuid=str(uuid4()), if not scientific:
name=name.strip(), scientific = PlantScientific(name=sci_name, common_id=common.id)
scientific_name_id=sci.id, db.session.add(scientific)
type=plant_type.strip(), db.session.flush()
status=plant_status.strip(),
description=desc.strip() # Plant
plant = Plant.query.filter_by(uuid=uuid).first()
if not plant:
plant = Plant(
uuid=uuid,
common_id=common.id,
scientific_id=scientific.id,
plant_type=plant_type
) )
db.session.add(new_plant) db.session.add(plant)
db.session.flush() # get new_plant.id for ownership log
# Log ownership # Neo4j
log = PlantOwnershipLog( neo.create_plant_node(uuid, name)
plant_id=new_plant.id, if mother_uuid and mother_uuid.strip():
user_id=current_user.id, neo.create_plant_node(mother_uuid.strip(), "Parent")
date_acquired=datetime.utcnow() neo.create_lineage(uuid, mother_uuid.strip())
)
db.session.add(log)
db.session.commit() db.session.commit()
flash(f"Successfully imported {len(rows)} plants.", "success") neo.close()
return redirect(url_for("importer.upload_csv"))
flash("CSV imported successfully with lineage.", "success")
except Exception as e: except Exception as e:
db.session.rollback() flash(f"Import failed: {str(e)}", "error")
flash(f"Error importing CSV: {str(e)}", "danger")
return redirect(request.url) return redirect(request.url)
return render_template("importer/upload.html", headers=", ".join(headers)) return render_template("importer/upload.html")

View File

@ -1,6 +1,6 @@
from app import db from app import db
from datetime import datetime from datetime import datetime
from plugins.plant.models import Plant
class Media(db.Model): class Media(db.Model):
__tablename__ = 'media' __tablename__ = 'media'
@ -10,7 +10,7 @@ class Media(db.Model):
file_url = db.Column(db.String(256), nullable=False) file_url = db.Column(db.String(256), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True) plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=True)
growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True) growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True)
update_id = db.Column(db.Integer, db.ForeignKey('plant_updates.id'), nullable=True) update_id = db.Column(db.Integer, db.ForeignKey('plant_updates.id'), nullable=True)

View File

@ -1,59 +1,84 @@
from flask_sqlalchemy import SQLAlchemy
import uuid as uuid_lib
from datetime import datetime from datetime import datetime
from app import db from app import db
from plugins.search.models import Tag, plant_tags
from plugins.growlog.models import PlantUpdate
class PlantCommonName(db.Model): # Association table for tags
__tablename__ = 'plants_common' plant_tags = db.Table(
id = db.Column(db.Integer, primary_key=True) 'plant_tags',
name = db.Column(db.String(120), unique=True, nullable=False) db.metadata,
db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True),
extend_existing=True
)
class Tag(db.Model):
__tablename__ = 'tag'
__table_args__ = {'extend_existing': True}
class PlantScientificName(db.Model):
__tablename__ = 'plants_scientific'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False) name = db.Column(db.String(255), unique=True, nullable=False)
class PlantCommonName(db.Model):
__tablename__ = 'plant_common_name'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
class PlantScientificName(db.Model):
__tablename__ = 'plant_scientific_name'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
class PlantLineage(db.Model): class PlantLineage(db.Model):
__tablename__ = 'plant_lineage' __tablename__ = 'plant_lineage'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
parent_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False) child_plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
child_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False) parent_plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
type = db.Column(db.String(50), nullable=False) # cutting, seed, division
class PlantOwnershipLog(db.Model): class PlantOwnershipLog(db.Model):
__tablename__ = 'plant_ownership_log' __tablename__ = 'plant_ownership_log'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False) plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
date_acquired = db.Column(db.DateTime, default=datetime.utcnow) start_time = db.Column(db.DateTime, nullable=False)
date_relinquished = db.Column(db.DateTime, nullable=True) end_time = db.Column(db.DateTime, nullable=True)
transfer_note = db.Column(db.Text, nullable=True)
class Plant(db.Model): class Plant(db.Model):
__tablename__ = 'plants' __tablename__ = 'plant'
id = db.Column(db.Integer, primary_key=True) __table_args__ = {'extend_existing': True}
common_name_id = db.Column(db.Integer, db.ForeignKey('plants_common.id'))
scientific_name_id = db.Column(db.Integer, db.ForeignKey('plants_scientific.id'))
parent_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
is_dead = db.Column(db.Boolean, default=False)
date_added = db.Column(db.DateTime, default=datetime.utcnow)
created_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
class Plant(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid4())) uuid = db.Column(db.String(36), default=lambda: str(uuid_lib.uuid4()), unique=True, nullable=False)
mother_uuid = db.Column(db.String(36), nullable=True) # Optional parent reference custom_slug = db.Column(db.String(255), unique=True, nullable=True)
name_id = db.Column(db.Integer, db.ForeignKey("plant_common_name.id"), nullable=True)
scientific_name_id = db.Column(db.Integer, db.ForeignKey("plant_scientific_name.id"), nullable=True) owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
scientific_id = db.Column(db.Integer, db.ForeignKey('plant_scientific_name.id'), nullable=False)
plant_type = db.Column(db.String(50), nullable=False) plant_type = db.Column(db.String(50), nullable=False)
status = db.Column(db.String(50), nullable=False) status = db.Column(db.String(50), nullable=False, default='active')
notes = db.Column(db.Text) notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
transferred = db.Column(db.Boolean, default=False)
graph_node_id = db.Column(db.String(255), nullable=True)
# Relationships # Relationships
updates = db.relationship('PlantUpdate', backref='growlog', lazy=True) updates = db.relationship('PlantUpdate', backref='growlog', lazy=True)
lineage = db.relationship('PlantLineage', backref='child', lazy=True, foreign_keys='PlantLineage.child_plant_id') lineage = db.relationship('PlantLineage', backref='child', lazy=True, foreign_keys='PlantLineage.child_plant_id')
tags = db.relationship('Tag', secondary=plant_tags, backref='plants') tags = db.relationship('Tag', secondary=plant_tags, backref='plants')
# → relationships so we can pull in the actual names:
common_name = db.relationship( common_name = db.relationship(
'PlantCommonName', 'PlantCommonName',
backref=db.backref('plants', lazy='dynamic'), backref=db.backref('plants', lazy='dynamic'),
@ -64,3 +89,6 @@ class Plant(db.Model):
backref=db.backref('plants', lazy='dynamic'), backref=db.backref('plants', lazy='dynamic'),
lazy=True lazy=True
) )
PlantCommon = PlantCommonName
PlantScientific = PlantScientificName

View File

@ -7,7 +7,7 @@ bp = Blueprint('plant', __name__, template_folder='templates')
@bp.route('/plants') @bp.route('/plants')
def index(): def index():
plants = Plant.query.order_by(Plant.date_added.desc()).all() plants = Plant.query.order_by(Plant.created_at.desc()).all()
return render_template('plant/index.html', plants=plants) return render_template('plant/index.html', plants=plants)
@bp.route('/plants/<int:plant_id>') @bp.route('/plants/<int:plant_id>')

View File

@ -16,7 +16,7 @@
{% endif %} {% endif %}
<dl class="row"> <dl class="row">
<dt class="col-sm-3">Date Added</dt> <dt class="col-sm-3">Date Added</dt>
<dd class="col-sm-9">{{ plant.date_added.strftime('%Y-%m-%d') }}</dd> <dd class="col-sm-9">{{ plant.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-sm-3">Status</dt> <dt class="col-sm-3">Status</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">

View File

@ -1,12 +1,17 @@
from app import db from app import db
#from plugins.plant.models import Plant
plant_tags = db.Table( # plant_tags = db.Table(
'plant_tags', # 'plant_tags',
db.Column('plant_id', db.Integer, db.ForeignKey('plants.id'), primary_key=True), # db.metadata,
db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True) # db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
) # db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
# extend_existing=True
# )
class Tag(db.Model): # class Tag(db.Model):
__tablename__ = 'tags' # __tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True) # __table_args__ = {'extend_existing': True}
name = db.Column(db.String(100), unique=True, nullable=False)
# id = db.Column(db.Integer, primary_key=True)
# name = db.Column(db.String(100), unique=True, nullable=False)

View File

@ -1,9 +1,8 @@
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from .models import Tag
from .forms import SearchForm from .forms import SearchForm
from plugins.plant.models import Plant from plugins.plant.models import Plant, Tag
bp = Blueprint('search', __name__, template_folder='templates') bp = Blueprint('search', __name__, template_folder='templates')

View File

@ -1,13 +1,15 @@
from datetime import datetime from datetime import datetime
from app import db from app import db
from plugins.plant.models import Plant
class Submission(db.Model): class Submission(db.Model):
__tablename__ = 'submissions' __tablename__ = 'submissions'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True) plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=True)
common_name = db.Column(db.String(120), nullable=False) common_name = db.Column(db.String(120), nullable=False)
scientific_name = db.Column(db.String(120)) scientific_name = db.Column(db.String(120))
@ -29,6 +31,7 @@ class Submission(db.Model):
class SubmissionImage(db.Model): class SubmissionImage(db.Model):
__tablename__ = 'submission_images' __tablename__ = 'submission_images'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False) submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False)

View File

@ -8,3 +8,5 @@ Werkzeug>=2.3.0
pymysql pymysql
python-dotenv python-dotenv
cryptography cryptography
neo4j>=5.18.0