From 507531fbff2c6a5b9b6f474d9d95bcea65440e29 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Thu, 11 Jun 2026 10:05:23 -0400 Subject: [PATCH] Changes for refactor of references view, add unique keys for some tables References sites table updated so we can filter by site later, added timestamp to tunes so they can be sorted in certain contexts, enforced uniqueness on some tables that were causing issues --- app/__init__.py | 2 + app/models.py | 33 ++++++-- app/routes_reference_sites.py | 43 ++++++++++ app/routes_sub.py | 4 +- .../c3f8a1e924d5_add_created_at_to_tunes.py | 29 +++++++ .../d7e3b19f0a2c_add_reference_sites.py | 68 +++++++++++++++ ...a2b3c4d1_unique_names_musicians_sources.py | 83 +++++++++++++++++++ 7 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 app/routes_reference_sites.py create mode 100644 migrations/versions/c3f8a1e924d5_add_created_at_to_tunes.py create mode 100644 migrations/versions/d7e3b19f0a2c_add_reference_sites.py create mode 100644 migrations/versions/e5f6a2b3c4d1_unique_names_musicians_sources.py diff --git a/app/__init__.py b/app/__init__.py index e648f13..1f43889 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,6 +9,7 @@ from app.routes_sub import sub_bp from app.routes_sources import sources_bp from app.routes_musicians import musicians_bp from app.routes_tunings import tunings_bp +from app.routes_reference_sites import reference_sites_bp def create_app(): @@ -37,6 +38,7 @@ def create_app(): app.register_blueprint(sources_bp) app.register_blueprint(musicians_bp) app.register_blueprint(tunings_bp) + app.register_blueprint(reference_sites_bp) # --- Error handlers --- from sqlalchemy.exc import IntegrityError diff --git a/app/models.py b/app/models.py index aa67a3c..68eaed7 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,5 @@ -from sqlalchemy import Boolean, Column, Date, ForeignKey, Integer, String, Table, UniqueConstraint +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Table, UniqueConstraint +from sqlalchemy.sql import func from app.extensions import db @@ -15,7 +16,7 @@ class Musician(db.Model): __tablename__ = "musicians" id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) + name = Column(String, nullable=False, unique=True) references = db.relationship("Reference", secondary=reference_musicians, back_populates="musicians") instrument_entries = db.relationship("TuneByInstrument", back_populates="learned_from_musician") @@ -28,7 +29,7 @@ class Source(db.Model): __tablename__ = "sources" id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, nullable=False) + name = Column(String, nullable=False, unique=True) tunes = db.relationship("Tune", back_populates="source") @@ -67,7 +68,8 @@ class Tune(db.Model): name = Column(String, nullable=True) key = Column(String, nullable=True) modal = Column(Boolean, nullable=True) - source_id = Column(Integer, ForeignKey("sources.id", ondelete="SET NULL"), nullable=True) + source_id = Column(Integer, ForeignKey("sources.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=True) source = db.relationship("Source", back_populates="tunes") instruments = db.relationship("TuneByInstrument", back_populates="tune", cascade="all, delete-orphan") @@ -81,6 +83,7 @@ class Tune(db.Model): "key": self.key, "modal": self.modal, "source_id": self.source_id, + "created_at": self.created_at.isoformat() if self.created_at else None, "source": self.source.to_dict() if self.source else None, "instruments": [i.to_dict() for i in self.instruments], "notes": [n.to_dict() for n in self.notes], @@ -154,22 +157,36 @@ class TuneByInstrumentNote(db.Model): return {"id": self.id, "tune_by_instrument_id": self.tune_by_instrument_id, "note": self.note} +class ReferenceSite(db.Model): + __tablename__ = "reference_sites" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False, unique=True) + + references = db.relationship("Reference", back_populates="site") + + def to_dict(self): + return {"id": self.id, "name": self.name} + + class Reference(db.Model): __tablename__ = "references" id = Column(Integer, primary_key=True, autoincrement=True) tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True) link = Column(String, nullable=True) - site = Column(String, nullable=True) + site_id = Column(Integer, ForeignKey("reference_sites.id", ondelete="SET NULL"), nullable=True) - tune = db.relationship("Tune", back_populates="references") - musicians = db.relationship("Musician", secondary=reference_musicians, back_populates="references") + tune = db.relationship("Tune", back_populates="references") + site = db.relationship("ReferenceSite", back_populates="references") + musicians = db.relationship("Musician", secondary=reference_musicians, back_populates="references") def to_dict(self): return { "id": self.id, "tune_id": self.tune_id, "link": self.link, - "site": self.site, + "site_id": self.site_id, + "site": self.site.to_dict() if self.site else None, "musicians": [m.to_dict() for m in self.musicians], } diff --git a/app/routes_reference_sites.py b/app/routes_reference_sites.py new file mode 100644 index 0000000..2e6f7a7 --- /dev/null +++ b/app/routes_reference_sites.py @@ -0,0 +1,43 @@ +"""Routes for reference_sites lookup table.""" +from flask import Blueprint, jsonify, request +from app.extensions import db +from app.models import ReferenceSite +from app.auth import require_api_key + +reference_sites_bp = Blueprint("reference_sites", __name__, url_prefix="/reference_sites") + + +@reference_sites_bp.get("/") +def list_reference_sites(): + sites = db.session.query(ReferenceSite).order_by(ReferenceSite.name).all() + return jsonify([s.to_dict() for s in sites]) + + +@reference_sites_bp.post("/") +@require_api_key +def create_reference_site(): + data = request.get_json(force=True) + site = ReferenceSite(name=data["name"]) + db.session.add(site) + db.session.commit() + return jsonify(site.to_dict()), 201 + + +@reference_sites_bp.patch("/") +@require_api_key +def update_reference_site(site_id): + site = db.get_or_404(ReferenceSite, site_id) + data = request.get_json(force=True) + if "name" in data: + site.name = data["name"] + db.session.commit() + return jsonify(site.to_dict()) + + +@reference_sites_bp.delete("/") +@require_api_key +def delete_reference_site(site_id): + site = db.get_or_404(ReferenceSite, site_id) + db.session.delete(site) + db.session.commit() + return jsonify({"deleted": site_id}) diff --git a/app/routes_sub.py b/app/routes_sub.py index 7f94be0..e1dcc6f 100644 --- a/app/routes_sub.py +++ b/app/routes_sub.py @@ -65,7 +65,7 @@ def list_references(tune_id): def add_reference(tune_id): db.get_or_404(Tune, tune_id) data = request.get_json(force=True) - ref = Reference(tune_id=tune_id, link=data.get("link"), site=data.get("site")) + ref = Reference(tune_id=tune_id, link=data.get("link"), site_id=data.get("site_id")) db.session.add(ref) db.session.flush() for m in data.get("musicians", []): @@ -81,7 +81,7 @@ def add_reference(tune_id): def update_reference(tune_id, ref_id): ref = db.get_or_404(Reference, ref_id) data = request.get_json(force=True) - for field in ("link", "site"): + for field in ("link", "site_id"): if field in data: setattr(ref, field, data[field]) db.session.commit() diff --git a/migrations/versions/c3f8a1e924d5_add_created_at_to_tunes.py b/migrations/versions/c3f8a1e924d5_add_created_at_to_tunes.py new file mode 100644 index 0000000..e59b538 --- /dev/null +++ b/migrations/versions/c3f8a1e924d5_add_created_at_to_tunes.py @@ -0,0 +1,29 @@ +"""add created_at to tunes + +Revision ID: c3f8a1e924d5 +Revises: 57326830536b +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "c3f8a1e924d5" +down_revision = "57326830536b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "tunes", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("tunes", "created_at") diff --git a/migrations/versions/d7e3b19f0a2c_add_reference_sites.py b/migrations/versions/d7e3b19f0a2c_add_reference_sites.py new file mode 100644 index 0000000..20bd336 --- /dev/null +++ b/migrations/versions/d7e3b19f0a2c_add_reference_sites.py @@ -0,0 +1,68 @@ +"""add reference_sites and migrate references.site to FK + +Revision ID: d7e3b19f0a2c +Revises: c3f8a1e924d5 +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column, select + +revision = "d7e3b19f0a2c" +down_revision = "c3f8a1e924d5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create reference_sites table + op.create_table( + "reference_sites", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(), nullable=False, unique=True), + ) + + # 2. Add site_id FK column (nullable) + op.add_column( + "references", + sa.Column("site_id", sa.Integer(), + sa.ForeignKey("reference_sites.id", ondelete="SET NULL"), + nullable=True), + ) + + # 3. Migrate existing site string data + conn = op.get_bind() + refs_t = table("references", column("id", sa.Integer), column("site", sa.String), column("site_id", sa.Integer)) + sites_t = table("reference_sites", column("id", sa.Integer), column("name", sa.String)) + + # Collect distinct non-null site strings + existing = conn.execute( + select(refs_t.c.site).where(refs_t.c.site.isnot(None)).distinct() + ).fetchall() + + for (name,) in existing: + # Insert into reference_sites (ignore duplicates) + result = conn.execute( + sites_t.insert().returning(sites_t.c.id).values(name=name) + ) + site_id = result.fetchone()[0] + # Update references rows with this site string + conn.execute( + refs_t.update().where(refs_t.c.site == name).values(site_id=site_id) + ) + + # 4. Drop old site string column + op.drop_column("references", "site") + + +def downgrade() -> None: + op.add_column("references", sa.Column("site", sa.String(), nullable=True)) + # best-effort: restore site name from FK + conn = op.get_bind() + refs_t = table("references", column("id", sa.Integer), column("site", sa.String), column("site_id", sa.Integer)) + sites_t = table("reference_sites", column("id", sa.Integer), column("name", sa.String)) + rows = conn.execute(select(refs_t.c.id, sites_t.c.name).join(sites_t, refs_t.c.site_id == sites_t.c.id)).fetchall() + for ref_id, name in rows: + conn.execute(refs_t.update().where(refs_t.c.id == ref_id).values(site=name)) + op.drop_column("references", "site_id") + op.drop_table("reference_sites") diff --git a/migrations/versions/e5f6a2b3c4d1_unique_names_musicians_sources.py b/migrations/versions/e5f6a2b3c4d1_unique_names_musicians_sources.py new file mode 100644 index 0000000..d8a8820 --- /dev/null +++ b/migrations/versions/e5f6a2b3c4d1_unique_names_musicians_sources.py @@ -0,0 +1,83 @@ +"""unique name constraints on musicians and sources + +Revision ID: e5f6a2b3c4d1 +Revises: d7e3b19f0a2c +Create Date: 2026-06-09 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + +revision = "e5f6a2b3c4d1" +down_revision = "d7e3b19f0a2c" +branch_labels = None +depends_on = None + + +def _dedup_musicians(conn): + """Keep the lowest-id musician for each name; re-point FKs and delete dupes.""" + rows = conn.execute(text( + "SELECT MIN(id) AS keep_id, LOWER(name) AS lname " + "FROM musicians GROUP BY LOWER(name) HAVING COUNT(*) > 1" + )).fetchall() + + for row in rows: + keep_id = row[0] + lname = row[1] + dupe_ids = [r[0] for r in conn.execute( + text("SELECT id FROM musicians WHERE LOWER(name) = :n AND id != :k"), + {"n": lname, "k": keep_id} + ).fetchall()] + + for dupe_id in dupe_ids: + # Re-point learned_from_id in tune_by_instrument + conn.execute(text( + "UPDATE tune_by_instrument SET learned_from_id = :k " + "WHERE learned_from_id = :d" + ), {"k": keep_id, "d": dupe_id}) + # Re-point reference_musicians (skip if keep_id row already exists for that ref) + conn.execute(text( + "DELETE FROM reference_musicians WHERE musician_id = :d " + "AND reference_id IN (" + " SELECT reference_id FROM reference_musicians WHERE musician_id = :k" + ")" + ), {"d": dupe_id, "k": keep_id}) + conn.execute(text( + "UPDATE reference_musicians SET musician_id = :k WHERE musician_id = :d" + ), {"k": keep_id, "d": dupe_id}) + conn.execute(text("DELETE FROM musicians WHERE id = :d"), {"d": dupe_id}) + + +def _dedup_sources(conn): + """Keep the lowest-id source for each name; re-point tunes.source_id and delete dupes.""" + rows = conn.execute(text( + "SELECT MIN(id) AS keep_id, LOWER(name) AS lname " + "FROM sources GROUP BY LOWER(name) HAVING COUNT(*) > 1" + )).fetchall() + + for row in rows: + keep_id = row[0] + lname = row[1] + dupe_ids = [r[0] for r in conn.execute( + text("SELECT id FROM sources WHERE LOWER(name) = :n AND id != :k"), + {"n": lname, "k": keep_id} + ).fetchall()] + + for dupe_id in dupe_ids: + conn.execute(text( + "UPDATE tunes SET source_id = :k WHERE source_id = :d" + ), {"k": keep_id, "d": dupe_id}) + conn.execute(text("DELETE FROM sources WHERE id = :d"), {"d": dupe_id}) + + +def upgrade() -> None: + conn = op.get_bind() + _dedup_musicians(conn) + _dedup_sources(conn) + op.create_unique_constraint("uq_musicians_name", "musicians", ["name"]) + op.create_unique_constraint("uq_sources_name", "sources", ["name"]) + + +def downgrade() -> None: + op.drop_constraint("uq_musicians_name", "musicians", type_="unique") + op.drop_constraint("uq_sources_name", "sources", type_="unique")