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")