Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
227 changes: 227 additions & 0 deletions atr/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import asyncio
import collections
import dataclasses
import datetime
import hashlib
import ipaddress
Expand All @@ -36,6 +37,7 @@
import jwt
import pydantic
import quart
import quart.datastructures as datastructures
import sqlalchemy
import sqlmodel
import werkzeug.exceptions as exceptions
Expand All @@ -61,6 +63,9 @@
import atr.paths as paths
import atr.principal as principal
import atr.shared as shared
import atr.shared.catalogue_diff as catalogue_diff
import atr.shared.catalogue_import as catalogue_import
import atr.shared.catalogue_rows as catalogue_rows
import atr.storage as storage
import atr.storage.outcome as outcome
import atr.tasks as tasks
Expand Down Expand Up @@ -680,6 +685,228 @@ async def catalog_remove_post(
return await session.redirect(catalog_committee_get, committee_key=str(committee_key))


_CATALOG_PREVIEW_LIMIT: Final[int] = 50


class CatalogImportForm(form.Form):
projects: form.File = form.label("Projects CSV", "Optional.")
releases: form.File = form.label("Releases CSV", "Optional.")
artifacts: form.File = form.label("Artifacts CSV", "Optional.")
additive: bool = form.label(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is default=True, but because browsers don't send anything when the field is unchecked, that's when we apply the default. So when the field is checked, the browser sends "on" and we set True. If the field is unchecked, the browser sends nothing, and we use the default, which is True. Therefore it's impossible to turn this field off.

I wonder if we can add a static lint for this. Might be difficult because the field element is a consequence of the Python type of the property.

"Add and update only",
"Leave anything the files do not list alone, and match rows against what is already there."
" Unchecking this will overwrite all existing content for this PMC",
default=True,
)


class CatalogImportApplyForm(form.Form):
token: str = form.label("Token", widget=form.Widget.HIDDEN)


@dataclasses.dataclass
class CatalogExportArgs:
table: str = ""


@admin.typed
async def catalog_export_get(
_session: web.Committer,
_catalog: Literal["catalog"],
committee_key: safe.CommitteeKey,
_export: Literal["export"],
query_args: CatalogExportArgs,
) -> web.QuartResponse:
"""
URL: GET /catalog/<committee_key>/export

Download the committee's current entries for one table as a CSV.
"""
table = query_args.table
async with db.session() as data:
try:
content = await catalogue_import.export_table(data, str(committee_key), table)
except KeyError as error:
raise exceptions.NotFound(f"Unknown table '{table}'.") from error
headers = {"Content-Disposition": f'attachment; filename="{committee_key}-{table}.csv"'}
return quart.Response(content, mimetype="text/csv", headers=headers)


@admin.typed
async def catalog_import_get(
_session: web.Committer,
_catalog: Literal["catalog"],
committee_key: safe.CommitteeKey,
_import: Literal["import"],
) -> str:
"""
URL: GET /catalog/<committee_key>/import

Upload catalogue CSVs to preview against the committee.
"""
rendered_form = await form.render(
model_cls=CatalogImportForm,
submit_label="Preview import",
submit_classes="btn-primary",
)
return await template.render("catalog-import.html", committee_key=str(committee_key), form=rendered_form)


@admin.typed
async def catalog_import_post(
_session: web.Committer,
_catalog: Literal["catalog"],
committee_key: safe.CommitteeKey,
_import: Literal["import"],
import_form: CatalogImportForm,
) -> str:
"""
URL: POST /catalog/<committee_key>/import

Store the uploads and show the classified diff, grouped by table.
"""
files = _catalog_import_files(import_form)
if not files:
raise exceptions.BadRequest("Upload at least one CSV.")
mode = catalogue_diff.Mode.ADDITIVE if import_form.additive else catalogue_diff.Mode.REPLACE
token = await catalogue_import.store_uploads(files, str(committee_key), mode)
rows = await asyncio.to_thread(catalogue_import.read_uploads, token)
async with db.session() as data:
diff = await _catalog_import_diff(data, str(committee_key), rows, mode)
apply_form = await form.render(
model_cls=CatalogImportApplyForm,
action=util.as_url(catalog_import_apply_post, committee_key=str(committee_key)),
submit_label="Apply import",
submit_classes="btn-danger",
defaults={"token": token},
)
return await template.render(
"catalog-import-preview.html",
committee_key=str(committee_key),
preview=_catalog_import_preview(diff),
form=apply_form,
)


@admin.typed
async def catalog_import_apply_post(
session: web.Committer,
_catalog: Literal["catalog"],
committee_key: safe.CommitteeKey,
_import: Literal["import"],
_apply: Literal["apply"],
apply_form: CatalogImportApplyForm,
) -> web.WerkzeugResponse:
"""
URL: POST /catalog/<committee_key>/import/apply

Re-read the stored uploads, recompute the diff under the write lock, and apply it.
"""
token = apply_form.token.strip()
try:
rows = await asyncio.to_thread(catalogue_import.read_uploads, token)
except KeyError as error:
raise exceptions.BadRequest("The upload has expired; please upload again.") from error
if await asyncio.to_thread(catalogue_import.committee_of, token) != str(committee_key):
raise exceptions.BadRequest("That upload was previewed against a different committee.")
mode = await asyncio.to_thread(catalogue_import.mode_of, token)
try:
async with storage.write(session) as write:
catalogue_writer = write.as_foundation_admin().catalogue
diff = await catalogue_writer.import_catalogue_csvs(rows, str(committee_key), mode)
finally:
await asyncio.to_thread(catalogue_import.discard, token)
counts = diff.counts
if diff.refused:
raise exceptions.BadRequest(f"Nothing was imported: {counts['conflict']} conflicts. Upload again to see them.")
return await session.redirect(
catalog_committee_get,
committee_key=str(committee_key),
success=f"Imported: {_catalog_import_summary(diff)}",
)


def _catalog_import_files(import_form: CatalogImportForm) -> dict[str, datastructures.FileStorage]:
# Each table has its own optional picker, so which CSV is which comes from the field, not
# the uploaded file's name
files: dict[str, datastructures.FileStorage] = {}
for name in ("projects", "releases", "artifacts"):
upload = getattr(import_form, name)
if (upload is not None) and upload.filename:
files[name] = upload
return files


async def _catalog_import_diff(
data: db.Session, committee_key: str, rows: dict[str, list[dict[str, str]]], mode: catalogue_diff.Mode
) -> catalogue_diff.CatalogueDiff:
snapshot = await catalogue_import.build_snapshot(data, committee_key)
return catalogue_diff.classify(rows, snapshot, committee_key, mode)


def _catalog_capped(items: list[str]) -> list[htm.Element]:
shown = [htpy.li[item] for item in items[:_CATALOG_PREVIEW_LIMIT]]
if len(items) > _CATALOG_PREVIEW_LIMIT:
shown.append(htpy.li[f"... and {len(items) - _CATALOG_PREVIEW_LIMIT} more"])
return shown


def _catalog_import_summary(diff: catalogue_diff.CatalogueDiff) -> str:
counts = diff.counts
parts = [f"{counts['add']} adds", f"{counts['delete']} deletes"]
if counts["release_move"]:
parts.append(f"{counts['release_move']} release moves")
if counts["artifact_rehome"]:
parts.append(f"{counts['artifact_rehome']} artifact re-homes")
if counts["unchanged"]:
parts.append(f"{counts['unchanged']} unchanged")
parts.append(f"{counts['conflict']} conflicts")
return ", ".join(parts) + "."


def _catalog_import_preview(diff: catalogue_diff.CatalogueDiff) -> htm.Element:
sections: list[htm.Element] = [htpy.p[_catalog_import_summary(diff)]]
for warning in diff.warnings:
sections.append(htm.div(".alert.alert-warning")[warning])
if diff.project_deletions or diff.release_deletions or diff.artifact_deletions:
sections.append(htpy.h2["Deletions"])
sections.append(
htm.div(".alert.alert-danger")[
"Your files replace this committee's catalogue, so these are removed. Check you have"
" all the data to import, or that you are happy for anything unspecified to be deleted."
]
)
deletions = [f"project {key}, and its releases and artifacts" for key in diff.project_deletions]
deletions += [f"release {key}, and its artifacts" for key in diff.release_deletions]
deletions += [f"artifact {pk[2]} from {pk[0]} {pk[1]}" for pk in diff.artifact_deletions]
sections.append(htpy.ul[_catalog_capped(deletions)])
if diff.conflicts:
sections.append(htpy.h2["Conflicts"])
sections.append(htpy.ul[_catalog_capped([f"{c.table} {c.key}: {c.reason}" for c in diff.conflicts])])
project_adds = [add for add in diff.adds if isinstance(add, catalogue_rows.ProjectRow)]
release_adds = [add for add in diff.adds if isinstance(add, catalogue_rows.ReleaseRow)]
artifact_adds = [add for add in diff.adds if isinstance(add, catalogue_rows.ArtifactRow)]
if project_adds or release_adds:
sections.append(htpy.h2["Projects and releases to add"])
sections.append(htpy.ul[_catalog_capped([str(add.key) for add in project_adds + release_adds])])
if diff.release_moves:
sections.append(htpy.h2["Release moves"])
sections.append(htpy.ul[_catalog_capped([_catalog_move_label(move) for move in diff.release_moves])])
if artifact_adds or diff.artifact_rehomes:
sections.append(htpy.h2["Artifacts"])
rows = [htpy.li[f"add {add.artifact_path} to {add.project_key} {add.version}"] for add in artifact_adds]
rows += [
htpy.li[f"re-home {rehome.dist[1]} to {rehome.to_row.project_key} {rehome.to_row.version}"]
for rehome in diff.artifact_rehomes
]
sections.append(htpy.ul[rows])
return htpy.div[sections]


def _catalog_move_label(move: catalogue_diff.ReleaseMove) -> str:
return f"{move.key} to {move.to_project}"


@admin.typed
async def configuration(_session: web.Committer, _configuration: Literal["configuration"]) -> web.QuartResponse:
"""
Expand Down
1 change: 1 addition & 0 deletions atr/admin/templates/catalog-committee.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
{% block content %}
<h1>Catalogue: {{ committee_key }}</h1>
<p><a href="/admin/catalog">Back to committees</a></p>
<p><a href="/admin/catalog/{{ committee_key }}/import" class="btn btn-primary">Import CSVs</a></p>
{{ projects }}
{% endblock content %}
12 changes: 12 additions & 0 deletions atr/admin/templates/catalog-import-preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% extends "layouts/base-admin.html" %}

{%- block title -%}Import preview: {{ committee_key }} ~ ATR{%- endblock title -%}

{%- block description -%}Review the catalogue import for {{ committee_key }}.{%- endblock description -%}

{% block content %}
<h1>Import preview: {{ committee_key }}</h1>
<p><a href="/admin/catalog/{{ committee_key }}">Cancel</a></p>
{{ preview }}
{{ form }}
{% endblock content %}
56 changes: 56 additions & 0 deletions atr/admin/templates/catalog-import.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{% extends "layouts/base-admin.html" %}

{%- block title -%}Import catalogue: {{ committee_key }} ~ ATR{%- endblock title -%}

{%- block description -%}Upload catalogue CSVs to correct {{ committee_key }}.{%- endblock description -%}

{% block content %}
<h1>Import catalogue: {{ committee_key }}</h1>
<p><a href="/admin/catalog/{{ committee_key }}">Back to {{ committee_key }}</a></p>
<h2>Download current entries</h2>
<p>
Each file holds this committee's current entries, in the same columns the upload expects, so a
file you download can be edited and uploaded straight back.
</p>
<ul>
<li><a href="/admin/catalog/{{ committee_key }}/export?table=projects">projects.csv</a></li>
<li><a href="/admin/catalog/{{ committee_key }}/export?table=releases">releases.csv</a></li>
<li><a href="/admin/catalog/{{ committee_key }}/export?table=artifacts">artifacts.csv</a></li>
</ul>
<h2>Upload</h2>
<p>
The files you upload <strong>are</strong> this committee's catalogue. Download, edit, upload:
what the files list is what the committee ends up with, so an added row is an entry added, a
removed row is an entry deleted, and an edited row is an entry corrected. Nothing is written
until you confirm the preview on the next page.
</p>
<p>
Each file is optional, and the deepest one you upload decides how far the replacement reaches.
artifacts.csv replaces the artifacts; releases.csv replaces the releases and their artifacts;
projects.csv replaces the projects, their releases and their artifacts. Upload the levels below
the one you are correcting, or the preview will warn you what is about to be left empty.
</p>
<p>
The import applies as a whole or not at all. If any row cannot be applied it is reported as a
conflict, nothing is changed, and you can fix the file and upload again. This is deliberate: a
row that fell out would be deleted rather than corrected.
</p>
<p>
Because the rows are rewritten, columns the CSVs do not carry are reset. Run
<strong>Update projects</strong> afterwards to restore project metadata, and re-import
signatures and lifecycle events if you have them.
</p>
<p>
<strong>Add and update only</strong> turns the replacement off. Rows are matched against what
is already catalogued: a row that matches nothing is added, one that names a different project
moves there, and one that matches where it says it is does nothing. Nothing is deleted, a row
that cannot be applied is skipped and the rest still go ahead, and columns other than the keys
are not written back. Use it to top a committee up rather than to correct it.
</p>
<p>
Projects carrying live release-workflow data are never changed or deleted by this page, in
either mode. To move a project to another committee, or to rename or merge one, use the actions
on the committee page instead.
</p>
{{ form }}
{% endblock content %}
Loading