Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f6a4afc
Reimplement tags using an additional join table
ptormene Dec 24, 2025
a0be308
Add migration implementing tags with an additional join table
ptormene Dec 29, 2025
c2f501e
Fix migration format, using newlines correctly (only to separate SQL …
ptormene Dec 29, 2025
13548a8
Do not lose existing tags and job_tag assignments while restructuring…
ptormene Dec 29, 2025
712f240
Adapt the get_calcs query to the restructured job_tag and tag tables
ptormene Dec 29, 2025
1dda3b3
Add id primary key to JobTag; fix admin interface
ptormene Dec 29, 2025
455efde
Set Tag id as auto-incremental
ptormene Dec 29, 2025
f7427d8
Define the ordering in Job, Tag, TagJob models
ptormene Dec 29, 2025
3947aab
Handle errors adding/deleting JobTag objects more gracefully
ptormene Dec 29, 2025
d721b77
Better list JobTag objects in the admin
ptormene Dec 29, 2025
ece67d3
Fix check on error message checking not-empty tag name
ptormene Dec 29, 2025
c1969e9
Add and test API endpoints to create/delete a tag by name
ptormene Dec 29, 2025
b2f295e
Restructure the migration taking into account that SQLite cannot cond…
ptormene Dec 29, 2025
11e9661
Fix primary key issue
ptormene Dec 29, 2025
97aa2d0
Revert "Fix primary key issue"
ptormene Dec 29, 2025
2ca6ebd
Revert "Restructure the migration taking into account that SQLite can…
ptormene Dec 29, 2025
4ef9ca2
Change the order of some steps of the migration
ptormene Dec 29, 2025
3dab18d
Change the way tags are listed
ptormene Dec 30, 2025
c6a63d4
Change the way a tag is added or removed to/from a job
ptormene Dec 30, 2025
a74a5df
Fix a bug in the query to delete a tag
ptormene Dec 30, 2025
78f7e51
Make tag creation more robust under concurrency
ptormene Dec 30, 2025
866d535
Revert "Make tag creation more robust under concurrency"
ptormene Dec 30, 2025
df7d44d
In TagAdminSite specify the site_url taking into account settings.WEB…
ptormene Dec 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 113 additions & 29 deletions openquake/server/db/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,17 +578,7 @@ def get_calcs(db, request_get_dict, allowed_users, user_acl_on=False, id=None):
order_dir = request_get_dict.get('order_dir', 'DESC').upper()
if order_dir not in ('ASC', 'DESC'):
order_dir = 'DESC'

tags_query = """
GROUP_CONCAT(
CASE
WHEN t.is_preferred = 1 THEN t.tag || '★'
ELSE t.tag
END,
', '
) AS tags
"""

tags_query = "GROUP_CONCAT(tag_value, ', ') AS tags"
where_clause = f"?A AND ({users_filter} AND {user_name_like_filter}"
if include_shared:
where_clause += " OR j.status == 'shared'"
Expand All @@ -598,7 +588,12 @@ def get_calcs(db, request_get_dict, allowed_users, user_acl_on=False, id=None):
" AND j.id IN (SELECT job_id FROM job_tag WHERE is_preferred = 1)")
if filter_by_tag and filter_by_tag != '0':
where_clause += (
" AND j.id IN (SELECT job_id FROM job_tag WHERE tag = ?x)")
" AND j.id IN ("
"SELECT jt.job_id "
"FROM job_tag jt "
"JOIN tag tg ON tg.id = jt.tag_id "
"WHERE tg.name = ?x"
")")
query_params.append(filter_by_tag)

# NOTE: GROUP BY j.id returns one row per job (identified by j.id), even if that
Expand All @@ -607,7 +602,17 @@ def get_calcs(db, request_get_dict, allowed_users, user_acl_on=False, id=None):
query = f"""
SELECT j.*, {tags_query}
FROM job AS j
LEFT JOIN job_tag AS t ON j.id = t.job_id
LEFT JOIN (
SELECT
jt.job_id,
CASE
WHEN jt.is_preferred THEN t.name || '★'
ELSE t.name
END AS tag_value
FROM job_tag jt
JOIN tag t ON t.id = jt.tag_id
ORDER BY jt.is_preferred DESC, t.name
) jt ON jt.job_id = j.id
WHERE {where_clause}
GROUP BY j.id
ORDER BY {order_by} {order_dir}
Expand Down Expand Up @@ -669,10 +674,35 @@ def share_job(db, job_id, share):
f' from "{initial_status}" to "{new_status}"'}


def _get_or_create_tag_id(db, tag_name):
rows = db("SELECT id FROM tag WHERE name = ?x", tag_name)
if rows:
return rows[0].id

db("INSERT INTO tag (name) VALUES (?x)", tag_name)
rows = db("SELECT id FROM tag WHERE name = ?x", tag_name)
return rows[0].id


def _get_tag_id(db, tag_name):
"""
Resolve an existing tag name to tag_id.
Raises KeyError if the tag does not exist.
"""
rows = db("SELECT id FROM tag WHERE name = ?x", tag_name)
if not rows:
raise KeyError(f"Tag '{tag_name}' does not exist")
return rows[0].id


def add_tag_to_job(db, job_id, tag_name):
try:
db("INSERT INTO job_tag (job_id, tag, is_preferred) VALUES (?x, ?x, 0)",
job_id, tag_name)
tag_id = _get_or_create_tag_id(db, tag_name)
db(
"""
INSERT INTO job_tag (job_id, tag_id, is_preferred)
VALUES (?x, ?x, 0)
""", job_id, tag_id)
except Exception as exc:
return {'error': str(exc)}
else:
Expand All @@ -681,8 +711,14 @@ def add_tag_to_job(db, job_id, tag_name):

def remove_tag_from_job(db, job_id, tag_name):
try:
db("DELETE FROM job_tag WHERE job_id = ?x AND tag = ?x",
job_id, tag_name)
tag_id = _get_tag_id(db, tag_name)
except KeyError:
return {'success': f'Tag {tag_name} was not associated with job {job_id}'}
try:
db("""
DELETE FROM job_tag
WHERE job_id = ?x AND tag_id = ?x
""", job_id, tag_id)
except Exception as exc:
return {'error': str(exc)}
else:
Expand All @@ -691,18 +727,25 @@ def remove_tag_from_job(db, job_id, tag_name):

def set_preferred_job_for_tag(db, job_id, tag_name):
try:
tag_id = _get_or_create_tag_id(db, tag_name)

db("BEGIN")

db("""
UPDATE job_tag SET is_preferred = 0
WHERE tag = ?x AND is_preferred = 1
""", tag_name)
UPDATE job_tag
SET is_preferred = 0
WHERE tag_id = ?x AND is_preferred = 1
""", tag_id)

db("""
INSERT INTO job_tag (job_id, tag, is_preferred)
INSERT INTO job_tag (job_id, tag_id, is_preferred)
VALUES (?x, ?x, 1)
ON CONFLICT(job_id, tag) DO UPDATE
ON CONFLICT(job_id, tag_id) DO UPDATE
SET is_preferred = 1
""", job_id, tag_name)
""", job_id, tag_id)

db("COMMIT")

except Exception as exc:
return {'error': str(exc)}
else:
Expand All @@ -711,10 +754,18 @@ def set_preferred_job_for_tag(db, job_id, tag_name):

def unset_preferred_job_for_tag(db, tag_name):
try:
rows = db("SELECT id FROM tag WHERE name = ?x", tag_name)
if not rows:
return {'success': f'Tag {tag_name} has no preferred job now'}

tag_id = rows[0].id

db("""
UPDATE job_tag SET is_preferred = 0
WHERE tag = ?x AND is_preferred = 1
""", tag_name)
UPDATE job_tag
SET is_preferred = 0
WHERE tag_id = ?x AND is_preferred = 1
""", tag_id)

except Exception as exc:
return {'error': str(exc)}
else:
Expand All @@ -727,8 +778,10 @@ def get_preferred_job_for_tag(db, tag_name):
SELECT j.*
FROM job AS j
JOIN job_tag jt ON j.id = jt.job_id
WHERE jt.tag = ?x AND jt.is_preferred = 1
JOIN tag t ON t.id = jt.tag_id
WHERE t.name = ?x AND jt.is_preferred = 1
""", tag_name)

except Exception as exc:
return {'error': str(exc)}
else:
Expand All @@ -740,9 +793,40 @@ def get_preferred_job_for_tag(db, tag_name):
return {'error': f'Unexpected multiple preferred jobs for tag {tag_name}'}


def create_tag(db, name):
try:
rows = db("SELECT id FROM tag WHERE name = ?x", name)
if rows:
return {'success': f'Tag {name} already exists'}
db("INSERT INTO tag (name) VALUES (?x)", name)
except Exception as exc:
return {'error': str(exc)}
else:
return {'success': f'Tag {name} was created'}


def delete_tag(db, name):
try:
rows = db("SELECT id FROM tag WHERE name = ?x", name)
if not rows:
return {'success': f'Tag {name} does not exist'}

tag_id = rows[0].id

db("""
DELETE from tag
WHERE id = ?x
""", tag_id)

except Exception as exc:
return {'error': str(exc)}
else:
return {'success': f'Tag {name} was deleted'}


def list_tags(db):
rows = db("SELECT DISTINCT tag FROM job_tag ORDER BY tag")
tags = [row.tag for row in rows]
rows = db("SELECT name FROM tag ORDER BY name")
tags = [row.name for row in rows]
return {'success': 'ok', 'tags': tags}


Expand Down
51 changes: 34 additions & 17 deletions openquake/server/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with OpenQuake. If not, see <http://www.gnu.org/licenses/>.

from django.db import models, connection
from django.db import models


class Job(models.Model):
Expand All @@ -28,44 +28,61 @@ class Job(models.Model):
class Meta:
managed = False # the schema is not managed by Django
db_table = 'job'
ordering = ['-id'] # descending by id

def __str__(self):
return f"{self.id} – {self.description[:80]}" # show first 80 chars


class Tag(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255, unique=True)

class Meta:
managed = False
db_table = "tag"
verbose_name = "Tag"
verbose_name_plural = "Tags"
ordering = ['name']

def __str__(self):
return self.name


class JobTag(models.Model):
id = models.AutoField(primary_key=True)
job = models.ForeignKey(
Job,
on_delete=models.CASCADE,
db_column="job_id",
related_name="job_tags",
)
tag = models.ForeignKey(
Tag,
on_delete=models.CASCADE,
db_column="tag_id",
related_name="job_tags",
)
tag = models.CharField(max_length=255)
is_preferred = models.BooleanField(default=False)

class Meta:
managed = False
db_table = 'job_tag'
managed = False # the schema is not managed by Django
unique_together = ("job", "tag")
ordering = ['job_id', 'tag_id']
indexes = [
models.Index(
fields=['tag'],
name='uq_preferred_per_tag',
fields=["tag_id"],
name="uq_preferred_per_tag",
condition=models.Q(is_preferred=True),
)
]

verbose_name = "Job Tag"
verbose_name_plural = "Job Tags"

def __str__(self):
return (f"{self.tag} (job_id={self.job_id},"
f" {'preferred' if self.is_preferred else 'not preferred'})")

@property
def job_description(self):
"""Return the job description (queried directly from the job table)."""
with connection.cursor() as cursor:
cursor.execute(
"SELECT description FROM job WHERE id = %s", [self.job_id]
)
row = cursor.fetchone()
return row[0] if row else "(unknown)"
return (
f"{self.tag.name} (job_id={self.job_id}, "
f"{'preferred' if self.is_preferred else 'not preferred'})"
)
54 changes: 54 additions & 0 deletions openquake/server/db/schema/upgrades/0010-job-tag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- WARNING
-- This migration RESTRUCTURES the job/tag schema.
-- It preserves existing data by migrating it from the old
-- job_tag(job_id, tag, is_preferred) table into a normalized
-- schema with:
-- - tag(id, name)
-- - job_tag(job_id, tag_id, is_preferred)
-- Apply only once.

-- Remove old constraint/index if present
DROP INDEX IF EXISTS uq_preferred_per_tag;

-- Rename old table (do NOT drop yet)
ALTER TABLE job_tag RENAME TO job_tag_old;

-- Create new tables
CREATE TABLE tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE CHECK (LENGTH(name) > 0)
);

CREATE TABLE job_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
is_preferred INTEGER NOT NULL DEFAULT 0
CHECK (is_preferred IN (0, 1)),
UNIQUE (job_id, tag_id),
FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
);

-- Migrate tag values
INSERT INTO tag (name)
SELECT DISTINCT tag
FROM job_tag_old;

-- Migrate job-tag relations
INSERT INTO job_tag (job_id, tag_id, is_preferred)
SELECT
jto.job_id,
t.id,
jto.is_preferred
FROM job_tag_old AS jto
JOIN tag AS t
ON t.name = jto.tag;

-- Drop old table
DROP TABLE job_tag_old;

-- Recreate constraint, consistent with the new schema
CREATE UNIQUE INDEX uq_preferred_per_tag
ON job_tag(tag_id)
WHERE is_preferred = 1;
Loading