datatracker/ietf/doc/views_statement.py
Robert Sparks 997239a2ea
feat: write objects to blob storage (#8557)
* feat: basic blobstore infrastructure for dev

* refactor: (broken) attempt to put minio console behind nginx

* feat: initialize blobstore with boto3

* fix: abandon attempt to proxy minio. Use docker compose instead.

* feat: beginning of blob writes

* feat: storage utilities

* feat: test buckets

* chore: black

* chore: remove unused import

* chore: avoid f string when not needed

* fix: inform all settings files about blobstores

* fix: declare types for some settings

* ci: point to new target base

* ci: adjust test workflow

* fix: give the tests debug environment a blobstore

* fix: "better" name declarations

* ci: use devblobstore container

* chore: identify places to write to blobstorage

* chore: remove unreachable code

* feat: store materials

* feat: store statements

* feat: store status changes

* feat: store liaison attachments

* feat: store agendas provided with Interim session requests

* chore: capture TODOs

* feat: store polls and chatlogs

* chore: remove unneeded TODO

* feat: store drafts on submit and post

* fix: handle storage during doc expiration and resurrection

* fix: mirror an unlink

* chore: add/refine TODOs

* feat: store slide submissions

* fix: structure slide test correctly

* fix: correct sense of existence check

* feat: store some indexes

* feat: BlobShadowFileSystemStorage

* feat: shadow floorplans / host logos to the blob

* chore: remove unused import

* feat: strip path from blob shadow names

* feat: shadow photos / thumbs

* refactor: combine photo and photothumb blob kinds

The photos / thumbs were already dropped in the same
directory, so let's not add a distinction at this point.

* style: whitespace

* refactor: use kwargs consistently

* chore: migrations

* refactor: better deconstruct(); rebuild migrations

* fix: use new class in mack patch

* chore: add TODO

* feat: store group index documents

* chore: identify more TODO

* feat: store reviews

* fix: repair merge

* chore: remove unnecessary TODO

* feat: StoredObject metadata

* fix: deburr some debugging code

* fix: only set the deleted timestamp once

* chore: correct typo

* fix: get_or_create vs get and test

* fix: avoid the questionable is_seekable helper

* chore: capture future design consideration

* chore: blob store cfg for k8s

* chore: black

* chore: copyright

* ci: bucket name prefix option + run Black

Adds/uses DATATRACKER_BLOB_STORE_BUCKET_PREFIX option. Other changes
are just Black styling.

* ci: fix typo in bucket name expression

* chore: parameters in app-configure-blobstore

Allows use with other blob stores.

* ci: remove verify=False option

* fix: don't return value from __init__

* feat: option to log timing of S3Storage calls

* chore: units

* fix: deleted->null when storing a file

* style: Black

* feat: log as JSON; refactor to share code; handle exceptions

* ci: add ietf_log_blob_timing option for k8s

* test: --no-manage-blobstore option for running tests

* test: use blob store settings from env, if set

* test: actually set a couple more storage opts

* feat: offswitch (#8541)

* feat: offswitch

* fix: apply ENABLE_BLOBSTORAGE to BlobShadowFileSystemStorage behavior

* chore: log timing of blob reads

* chore: import Config from botocore.config

* chore(deps): import boto3-stubs / botocore

botocore is implicitly imported, but make it explicit
since we refer to it directly

* chore: drop type annotation that mypy loudly ignores

* refactor: add storage methods via mixin

Shares code between Document and DocHistory without
putting it in the base DocumentInfo class, which
lacks the name field. Also makes mypy happy.

* feat: add timeout / retry limit to boto client

* ci: let k8s config the timeouts via env

* chore: repair merge resolution typo

* chore: tweak settings imports

* chore: simplify k8s/settings_local.py imports

---------

Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org>
2025-02-19 17:41:10 -06:00

281 lines
11 KiB
Python

# Copyright The IETF Trust 2023, All Rights Reserved
import debug # pyflakes: ignore
from pathlib import Path
from django import forms
from django.conf import settings
from django.http import FileResponse, Http404
from django.views.decorators.cache import cache_control
from django.shortcuts import get_object_or_404, render, redirect
from django.template.loader import render_to_string
from ietf.utils import markdown
from django.utils.html import escape
from ietf.doc.models import Document, DocEvent, NewRevisionDocEvent, State
from ietf.group.models import Group
from ietf.ietfauth.utils import role_required
from ietf.utils.text import xslugify
from ietf.utils.textupload import get_cleaned_text_file_content
CONST_PDF_REV_NOTICE = "The current revision of this statement is in pdf format"
@cache_control(max_age=3600)
def serve_pdf(self, name, rev=None):
doc = get_object_or_404(Document, name=name)
if rev is None:
rev = doc.rev
p = Path(doc.get_file_path()).joinpath(f"{doc.name}-{rev}.pdf")
if not p.exists():
raise Http404
else:
return FileResponse(p.open(mode="rb"), content_type="application/pdf")
class StatementUploadForm(forms.Form):
ACTIONS = [
("enter", "Enter content directly"),
("upload", "Upload content from file"),
]
statement_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
statement_file = forms.FileField(
label="Markdown or PDF source file to upload", required=False
)
statement_content = forms.CharField(
widget=forms.Textarea(attrs={"rows": 30}), required=False, strip=False
)
def clean(self):
def require_field(f):
if not self.cleaned_data.get(f):
self.add_error(f, forms.ValidationError("You must fill in this field."))
return False
else:
return True
submission_method = self.cleaned_data.get("statement_submission")
markdown_content = ""
if submission_method == "enter":
if require_field("statement_content"):
markdown_content = self.cleaned_data["statement_content"].replace(
"\r", ""
)
default_content = render_to_string(
"doc/statement/statement_template.md", {}
)
if markdown_content == default_content:
raise forms.ValidationError(
"The example content may not be saved. Edit it to contain the next revision statement content."
)
if markdown_content == CONST_PDF_REV_NOTICE:
raise forms.ValidationError(
"Not proceeding with the text noting that the current version is pdf. Did you mean to upload a new PDF?"
)
elif submission_method == "upload":
if require_field("statement_file"):
content_type = self.cleaned_data["statement_file"].content_type
acceptable_types = (
"application/pdf",
) + settings.DOC_TEXT_FILE_VALID_UPLOAD_MIME_TYPES
if not content_type.startswith(
acceptable_types
): # dances around decoration of types with encoding etc.
self.add_error(
"statement_file",
forms.ValidationError(
f"Unexpected content type: Expected one of {', '.join(acceptable_types)}"
),
)
elif content_type != "application/pdf":
markdown_content = get_cleaned_text_file_content(
self.cleaned_data["statement_file"]
)
if markdown_content != "":
try:
_ = markdown.liberal_markdown(markdown_content)
except Exception as e:
raise forms.ValidationError(f"Markdown processing failed: {e}")
@role_required("Secretariat")
def submit(request, name):
statement = get_object_or_404(Document, type="statement", name=name)
if request.method == "POST":
form = StatementUploadForm(request.POST, request.FILES)
if form.is_valid():
statement_submission = form.cleaned_data["statement_submission"]
writing_pdf = (
statement_submission == "upload"
and form.cleaned_data["statement_file"].content_type
== "application/pdf"
)
statement.rev = "%02d" % (int(statement.rev) + 1)
statement.uploaded_filename = (
f"{statement.name}-{statement.rev}.{'pdf' if writing_pdf else 'md'}"
)
e = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=statement,
by=request.user.person,
rev=statement.rev,
desc="New revision available",
)
statement.save_with_history([e])
markdown_content = ""
if statement_submission == "upload":
if not writing_pdf:
markdown_content = get_cleaned_text_file_content(
form.cleaned_data["statement_file"]
)
else:
markdown_content = form.cleaned_data["statement_content"]
with Path(statement.get_file_name()).open(
mode="wb" if writing_pdf else "w"
) as destination:
if writing_pdf:
f = form.cleaned_data["statement_file"]
for chunk in f.chunks():
destination.write(chunk)
f.seek(0)
statement.store_file(statement.uploaded_filename, f)
else:
destination.write(markdown_content)
statement.store_str(statement.uploaded_filename, markdown_content)
return redirect("ietf.doc.views_doc.document_main", name=statement.name)
else:
if statement.uploaded_filename.endswith("pdf"):
text = CONST_PDF_REV_NOTICE
else:
text = statement.text_or_error()
init = {
"statement_content": text,
"statement_submission": "enter",
}
form = StatementUploadForm(initial=init)
return render(
request, "doc/statement/upload_content.html", {"form": form, "doc": statement}
)
class NewStatementForm(StatementUploadForm):
group = forms.ModelChoiceField(
queryset=Group.objects.filter(acronym__in=["iab", "iesg"])
)
title = forms.CharField(max_length=255)
field_order = [
"group",
"title",
"statement_submission",
"statement_file",
"statement_content",
]
def name_from_title_and_group(self, title, group):
title_slug = xslugify(title)
if title_slug.startswith(f"{group.acronym}-"):
title_slug = title_slug[len(f"{group.acronym}-") :]
name = f"statement-{group.acronym}-{title_slug[:240]}"
return name.replace("_", "-")
def clean(self):
if all([field in self.cleaned_data for field in ["title", "group"]]):
title = self.cleaned_data["title"]
group = self.cleaned_data["group"]
name = self.name_from_title_and_group(title, group)
if name == self.name_from_title_and_group("", group):
self.add_error(
"title",
forms.ValidationError(
"The filename derived from this title is empty. Please include a few descriptive words using ascii or numeric characters"
),
)
if Document.objects.filter(name=name).exists():
self.add_error(
"title",
forms.ValidationError(
"This title produces a filename already used by an existing statement"
),
)
return super().clean()
@role_required("Secretariat")
def new_statement(request):
if request.method == "POST":
form = NewStatementForm(request.POST, request.FILES)
if form.is_valid():
statement_submission = form.cleaned_data["statement_submission"]
writing_pdf = (
statement_submission == "upload"
and form.cleaned_data["statement_file"].content_type
== "application/pdf"
)
group = form.cleaned_data["group"]
title = form.cleaned_data["title"]
name = form.name_from_title_and_group(title, group)
statement = Document.objects.create(
type_id="statement",
group=group,
name=name,
title=title,
abstract="",
rev="00",
uploaded_filename=f"{name}-00.{'pdf' if writing_pdf else 'md'}",
)
statement.set_state(State.objects.get(type_id="statement", slug="active"))
e1 = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=statement,
by=request.user.person,
rev=statement.rev,
desc="New revision available",
time=statement.time,
)
e2 = DocEvent.objects.create(
type="published_statement",
doc=statement,
rev=statement.rev,
by=request.user.person,
desc="Statement published",
time=statement.time,
)
statement.save_with_history([e1, e2])
markdown_content = ""
if statement_submission == "upload":
if not writing_pdf:
markdown_content = get_cleaned_text_file_content(
form.cleaned_data["statement_file"]
)
else:
markdown_content = form.cleaned_data["statement_content"]
with Path(statement.get_file_name()).open(
mode="wb" if writing_pdf else "w"
) as destination:
if writing_pdf:
f = form.cleaned_data["statement_file"]
for chunk in f.chunks():
destination.write(chunk)
f.seek(0)
statement.store_file(statement.uploaded_filename, f)
else:
destination.write(markdown_content)
statement.store_str(statement.uploaded_filename, markdown_content)
return redirect("ietf.doc.views_doc.document_main", name=statement.name)
else:
init = {
"statement_content": escape(
render_to_string(
"doc/statement/statement_template.md", {"settings": settings}
)
),
"statement_submission": "enter",
}
form = NewStatementForm(initial=init)
return render(request, "doc/statement/new_statement.html", {"form": form})