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
This commit is contained in:
Ian Keane 2026-06-11 10:05:23 -04:00
parent 064e672ab7
commit 507531fbff
7 changed files with 252 additions and 10 deletions

View file

@ -9,6 +9,7 @@ from app.routes_sub import sub_bp
from app.routes_sources import sources_bp from app.routes_sources import sources_bp
from app.routes_musicians import musicians_bp from app.routes_musicians import musicians_bp
from app.routes_tunings import tunings_bp from app.routes_tunings import tunings_bp
from app.routes_reference_sites import reference_sites_bp
def create_app(): def create_app():
@ -37,6 +38,7 @@ def create_app():
app.register_blueprint(sources_bp) app.register_blueprint(sources_bp)
app.register_blueprint(musicians_bp) app.register_blueprint(musicians_bp)
app.register_blueprint(tunings_bp) app.register_blueprint(tunings_bp)
app.register_blueprint(reference_sites_bp)
# --- Error handlers --- # --- Error handlers ---
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError

View file

@ -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 from app.extensions import db
@ -15,7 +16,7 @@ class Musician(db.Model):
__tablename__ = "musicians" __tablename__ = "musicians"
id = Column(Integer, primary_key=True, autoincrement=True) 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") references = db.relationship("Reference", secondary=reference_musicians, back_populates="musicians")
instrument_entries = db.relationship("TuneByInstrument", back_populates="learned_from_musician") instrument_entries = db.relationship("TuneByInstrument", back_populates="learned_from_musician")
@ -28,7 +29,7 @@ class Source(db.Model):
__tablename__ = "sources" __tablename__ = "sources"
id = Column(Integer, primary_key=True, autoincrement=True) 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") tunes = db.relationship("Tune", back_populates="source")
@ -68,6 +69,7 @@ class Tune(db.Model):
key = Column(String, nullable=True) key = Column(String, nullable=True)
modal = Column(Boolean, 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") source = db.relationship("Source", back_populates="tunes")
instruments = db.relationship("TuneByInstrument", back_populates="tune", cascade="all, delete-orphan") instruments = db.relationship("TuneByInstrument", back_populates="tune", cascade="all, delete-orphan")
@ -81,6 +83,7 @@ class Tune(db.Model):
"key": self.key, "key": self.key,
"modal": self.modal, "modal": self.modal,
"source_id": self.source_id, "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, "source": self.source.to_dict() if self.source else None,
"instruments": [i.to_dict() for i in self.instruments], "instruments": [i.to_dict() for i in self.instruments],
"notes": [n.to_dict() for n in self.notes], "notes": [n.to_dict() for n in self.notes],
@ -154,15 +157,28 @@ class TuneByInstrumentNote(db.Model):
return {"id": self.id, "tune_by_instrument_id": self.tune_by_instrument_id, "note": self.note} 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): class Reference(db.Model):
__tablename__ = "references" __tablename__ = "references"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True) tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True)
link = Column(String, 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") tune = db.relationship("Tune", back_populates="references")
site = db.relationship("ReferenceSite", back_populates="references")
musicians = db.relationship("Musician", secondary=reference_musicians, back_populates="references") musicians = db.relationship("Musician", secondary=reference_musicians, back_populates="references")
def to_dict(self): def to_dict(self):
@ -170,6 +186,7 @@ class Reference(db.Model):
"id": self.id, "id": self.id,
"tune_id": self.tune_id, "tune_id": self.tune_id,
"link": self.link, "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], "musicians": [m.to_dict() for m in self.musicians],
} }

View file

@ -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("/<int:site_id>")
@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("/<int:site_id>")
@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})

View file

@ -65,7 +65,7 @@ def list_references(tune_id):
def add_reference(tune_id): def add_reference(tune_id):
db.get_or_404(Tune, tune_id) db.get_or_404(Tune, tune_id)
data = request.get_json(force=True) 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.add(ref)
db.session.flush() db.session.flush()
for m in data.get("musicians", []): for m in data.get("musicians", []):
@ -81,7 +81,7 @@ def add_reference(tune_id):
def update_reference(tune_id, ref_id): def update_reference(tune_id, ref_id):
ref = db.get_or_404(Reference, ref_id) ref = db.get_or_404(Reference, ref_id)
data = request.get_json(force=True) data = request.get_json(force=True)
for field in ("link", "site"): for field in ("link", "site_id"):
if field in data: if field in data:
setattr(ref, field, data[field]) setattr(ref, field, data[field])
db.session.commit() db.session.commit()

View file

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

View file

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

View file

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