Skip to content

Commit caa2b8c

Browse files
committed
Merge branch '5732-make-slug-permanent' of github.com:GSA/datagov-harvester into develop
2 parents 5097b26 + d351227 commit caa2b8c

File tree

17 files changed

+1431
-703
lines changed

17 files changed

+1431
-703
lines changed

app/commands/testdata.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,50 @@
22
from flask import Blueprint
33

44
from database.interface import HarvesterDBInterface
5+
from database.models import db
56

67
testdata = Blueprint("testdata", __name__)
78

8-
db = HarvesterDBInterface()
9+
db_interface = HarvesterDBInterface()
910

1011

1112
## Load Test Data
1213
# TODO move this into its own file when you break up routes
1314
@testdata.cli.command("load_test_data")
14-
def fixtures():
15-
"""Load database fixtures from JSON."""
16-
15+
@click.option(
16+
"--reset",
17+
is_flag=True,
18+
default=False,
19+
help="Drop and recreate the database before loading fixtures.",
20+
)
21+
def fixtures(reset: bool) -> None:
22+
"""
23+
Load database fixtures from JSON.
24+
25+
Use --reset to wipe the database and recreate all tables before loading.
26+
"""
1727
from tests.generate_fixtures import generate_dynamic_fixtures
1828

29+
if reset:
30+
db.drop_all()
31+
db.create_all()
32+
click.echo("Database reset: all tables dropped and recreated.")
33+
1934
fixture = generate_dynamic_fixtures()
2035

2136
for item in fixture["organization"]:
22-
db.add_organization(item)
37+
db_interface.add_organization(item)
2338
for item in fixture["source"]:
24-
db.add_harvest_source(item)
39+
db_interface.add_harvest_source(item)
2540
for item in fixture["job"]:
26-
db.add_harvest_job(item)
41+
db_interface.add_harvest_job(item)
2742
for item in fixture["job_error"]:
28-
db.add_harvest_job_error(item)
43+
db_interface.add_harvest_job_error(item)
2944
for item in fixture["record"]:
30-
db.add_harvest_record(item)
45+
db_interface.add_harvest_record(item)
3146
for item in fixture["record_error"]:
32-
db.add_harvest_record_error(item)
47+
db_interface.add_harvest_record_error(item)
48+
for item in fixture["dataset"]:
49+
db_interface.insert_dataset(item)
3350

3451
click.echo("Done.")

app/forms.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,46 @@ def validate_url(self, field):
231231
validators=[url_paste_validate],
232232
)
233233
submit = SubmitField("Validate")
234+
235+
class DatasetSlugForm(FlaskForm):
236+
"""
237+
Form for editing the slug of a Dataset.
238+
239+
Validates that the new slug is valid and unique across all
240+
datasets, excluding the dataset currently being edited.
241+
"""
242+
243+
slug = StringField(
244+
"Slug",
245+
description=(
246+
"Use lowercase letters, digits, and hyphens. "
247+
"For example: 'my-dataset-title'."
248+
),
249+
validators=[
250+
DataRequired(),
251+
Length(max=200),
252+
Regexp(
253+
r"^[a-z0-9-]+$",
254+
message=(
255+
"Slug can only contain lowercase letters, digits, and hyphens."
256+
),
257+
),
258+
],
259+
filters=[strip_filter],
260+
)
261+
262+
def __init__(self, *args, **kwargs):
263+
self.dataset_id = kwargs.pop("dataset_id", None)
264+
self.db_interface = kwargs.pop("db_interface", None)
265+
super().__init__(*args, **kwargs)
266+
267+
def validate_slug(self, field):
268+
from database.interface import HarvesterDBInterface
269+
270+
db_interface = self.db_interface or HarvesterDBInterface()
271+
existing = db_interface.get_dataset_by_slug(field.data)
272+
273+
if existing and existing.id != self.dataset_id:
274+
raise ValidationError(
275+
f"The slug '{field.data}' is already in use by another dataset."
276+
)

app/routes.py

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
)
5959
from .auth import LoginRequiredAuth
6060
from .forms import (
61+
DatasetSlugForm,
6162
HarvestSourceForm,
6263
HarvestTriggerForm,
6364
OrganizationForm,
@@ -540,11 +541,16 @@ def add_harvest_source():
540541
)
541542
@valid_id_required # TODO: Use an HTML 404 page
542543
def view_harvest_source(source_id: str):
543-
htmx_vars = {
544+
jobs_htmx_vars = {
544545
"target_div": "#paginated__harvest-jobs",
545546
"endpoint_url": f"/harvest_source/{source_id}",
546547
"page_param": "page",
547548
}
549+
datasets_htmx_vars = {
550+
"target_div": "#paginated__datasets",
551+
"endpoint_url": f"/harvest_source/{source_id}",
552+
"page_param": "datasets_page",
553+
}
548554
harvest_jobs_facets = (
549555
f"harvest_source_id eq {source_id},date_created le {get_datetime()}"
550556
)
@@ -565,17 +571,42 @@ def view_harvest_source(source_id: str):
565571
)
566572

567573
if htmx:
568-
data = {
569-
"source": {"id": source_id},
570-
"jobs": jobs,
571-
"htmx_vars": htmx_vars,
572-
}
573-
return render_block(
574-
"view_source_data.html",
575-
"htmx_paginated",
576-
data=data,
577-
pagination=pagination.to_dict(),
578-
)
574+
htmx_target = request.headers.get("HX-Target", "")
575+
576+
if "paginated__datasets" in htmx_target:
577+
datasets_pagination = Pagination(
578+
count=db.get_datasets_by_source(source_id=source_id, count=True),
579+
current=request.args.get("datasets_page", 1, type=convert_to_int),
580+
)
581+
datasets = db.get_datasets_by_source(
582+
source_id=source_id,
583+
page=datasets_pagination.db_current,
584+
per_page=datasets_pagination.per_page,
585+
)
586+
data = {
587+
"source": {"id": source_id},
588+
"datasets": datasets,
589+
"datasets_htmx_vars": datasets_htmx_vars,
590+
}
591+
return render_block(
592+
"view_source_data.html",
593+
"htmx_paginated_datasets",
594+
data=data,
595+
pagination=datasets_pagination.to_dict(),
596+
datasets_pagination=datasets_pagination.to_dict(),
597+
)
598+
else:
599+
data = {
600+
"source": {"id": source_id},
601+
"jobs": jobs,
602+
"htmx_vars": jobs_htmx_vars,
603+
}
604+
return render_block(
605+
"view_source_data.html",
606+
"htmx_paginated",
607+
data=data,
608+
pagination=pagination.to_dict(),
609+
)
579610
elif request.method == "POST":
580611
form = HarvestTriggerForm(request.form)
581612
if form.data["edit"]:
@@ -690,18 +721,32 @@ def view_harvest_source(source_id: str):
690721
],
691722
}
692723
source = db.get_harvest_source(source_id)
724+
datasets_page = request.args.get("datasets_page", 1, type=convert_to_int)
725+
datasets_count = db.get_datasets_by_source(source_id=source_id, count=True)
726+
datasets_pagination = Pagination(
727+
count=datasets_count,
728+
current=datasets_page,
729+
)
730+
datasets = db.get_datasets_by_source(
731+
source_id=source_id,
732+
page=datasets_pagination.db_current,
733+
per_page=datasets_pagination.per_page,
734+
)
693735
data = {
694736
"ckan_url": CKAN_URL,
695737
"source": source,
696738
"summary_data": summary_data,
697739
"jobs": jobs,
698740
"chart_data": chart_data,
699-
"htmx_vars": htmx_vars,
741+
"htmx_vars": jobs_htmx_vars,
742+
"datasets": datasets,
743+
"datasets_htmx_vars": datasets_htmx_vars,
700744
}
701745
return render_template(
702746
"view_source_data.html",
703747
form=form,
704748
pagination=pagination.to_dict(),
749+
datasets_pagination=datasets_pagination.to_dict(),
705750
data=data,
706751
)
707752

@@ -713,6 +758,66 @@ def harvest_source_list():
713758
return render_template("view_source_list.html", data=data)
714759

715760

761+
@main.route("/dataset/<string:dataset_slug>", methods=["POST"])
762+
@api.get("/dataset/<string:dataset_slug>")
763+
@api.doc(
764+
responses={
765+
200: {"description": "View dataset detail page"},
766+
404: {"description": "Dataset not found"},
767+
}
768+
)
769+
def view_dataset(dataset_slug: str):
770+
"""View a dataset detail page by slug, and handle slug edits via POST."""
771+
dataset = db.get_dataset_by_slug(dataset_slug)
772+
773+
if request.method == "POST":
774+
if not session.get("user"):
775+
flash("You must be logged in to edit a dataset slug.")
776+
return redirect(url_for("api.view_dataset", dataset_slug=dataset_slug))
777+
778+
form = DatasetSlugForm(
779+
request.form,
780+
dataset_id=dataset.id if dataset else None,
781+
db_interface=db,
782+
)
783+
if dataset is None:
784+
flash("Dataset not found.")
785+
return redirect(url_for("main.harvest_source_list"))
786+
787+
if form.validate():
788+
updated = db.update_dataset_slug(dataset.id, form.slug.data)
789+
if updated:
790+
flash(f"Slug updated successfully to '{updated.slug}'.")
791+
return redirect(url_for("api.view_dataset", dataset_slug=updated.slug))
792+
else:
793+
flash("Failed to update slug. Please try again.")
794+
return redirect(url_for("api.view_dataset", dataset_slug=dataset_slug))
795+
else:
796+
# Re-render the page with the form errors shown inline.
797+
data = {"dataset": dataset}
798+
return (
799+
render_template(
800+
"view_dataset_data.html",
801+
data=data,
802+
form=form,
803+
),
804+
422,
805+
)
806+
807+
# GET
808+
form = DatasetSlugForm(
809+
dataset_id=dataset.id if dataset else None,
810+
db_interface=db,
811+
data={"slug": dataset.slug} if dataset else {},
812+
)
813+
data = {"dataset": dataset}
814+
return render_template(
815+
"view_dataset_data.html",
816+
data=data,
817+
form=form,
818+
), (200 if dataset is not None else 404)
819+
820+
716821
### Edit Source
717822
@api.route("/harvest_source/edit/<source_id>", methods=["GET", "POST"])
718823
@api.doc(hide=True) # don't list the authenticated API

app/static/_scss/_uswds-theme-custom-styles.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ ul.menu {
8585
overflow: scroll;
8686
}
8787

88+
.usa-table.usa-table--striped {
89+
width: 100%;
90+
}
91+
8892
/* Keep Bootstrap 4 visual defaults while using Bootstrap 5 assets. */
8993
body {
9094
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,

0 commit comments

Comments
 (0)