diff --git a/HISTORY.rst b/HISTORY.rst index f9c819f..afea29e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,5 +1,9 @@ Changelog --------- + +- Allows to publish notices and to generate weekly PDFs. + [msom] + 1.12.1 (2017-12-11) ~~~~~~~~~~~~~~~~~~~ diff --git a/onegov/gazette/collections/issues.py b/onegov/gazette/collections/issues.py index 5c8fb37..c77a089 100644 --- a/onegov/gazette/collections/issues.py +++ b/onegov/gazette/collections/issues.py @@ -1,6 +1,8 @@ -from onegov.gazette.models import Issue +from collections import OrderedDict from onegov.core.collection import GenericCollection +from onegov.gazette.models import Issue from sedate import utcnow +from sqlalchemy import extract class IssueCollection(GenericCollection): @@ -17,3 +19,21 @@ def query(self): @property def current_issue(self): return self.query().filter(Issue.deadline > utcnow()).first() + + def by_name(self, name): + return self.query().filter(Issue.name == name).first() + + @property + def years(self): + years = self.session.query(extract('year', Issue.date).distinct()) + years = sorted([int(year[0]) for year in years]) + return years + + def by_years(self, desc=False): + issues = OrderedDict() + for year in sorted(self.years, reverse=desc): + query = self.query().filter(extract('year', Issue.date) == year) + if desc: + query = query.order_by(None).order_by(Issue.date.desc()) + issues[year] = query.all() + return issues diff --git a/onegov/gazette/fields.py b/onegov/gazette/fields.py index 8aa20b1..f17e122 100644 --- a/onegov/gazette/fields.py +++ b/onegov/gazette/fields.py @@ -17,7 +17,10 @@ def __init__(self, *args, **kwargs): class MultiCheckboxField(MultiCheckboxFieldBase): """ A multi checkbox field where only the first elements are display and - the the rest can be shown when needed. """ + the the rest can be shown when needed. + + Also, disables all the options if the whole field is disabled. + """ def __init__(self, *args, **kwargs): render_kw = kwargs.pop('render_kw', {}) @@ -36,6 +39,13 @@ def translate(self, request): self.render_kw['data-fold-title'] ) + def __iter__(self): + for opt in super(MultiCheckboxField, self).__iter__(): + if 'disabled' in self.render_kw: + opt.render_kw = opt.render_kw or {} + opt.render_kw['disabled'] = self.render_kw['disabled'] + yield opt + class DateTimeLocalField(DateTimeLocalFieldBase): """ A custom implementation of the DateTimeLocalField to fix issues with diff --git a/onegov/gazette/forms/notice.py b/onegov/gazette/forms/notice.py index 684484d..3f74f49 100644 --- a/onegov/gazette/forms/notice.py +++ b/onegov/gazette/forms/notice.py @@ -155,6 +155,8 @@ class UnrestrictedNoticeForm(NoticeForm): """ Edit an official notice without limitations on the issues, categories and organiaztions. + Optionally disables the issues (e.g. if the notice is already published). + """ def on_request(self): @@ -202,3 +204,18 @@ def title(item): # translate the string of the mutli select field self.issues.translate(self.request) + + def disable_issues(self): + self.issues.validators = [] + self.issues.render_kw['disabled'] = True + + def update_model(self, model): + model.title = self.title.data + model.organization_id = self.organization.data + model.category_id = self.category.data + model.text = self.text.data + model.at_cost = self.at_cost.data + if model.state != 'published': + model.issues = self.issues.data + + model.apply_meta(self.request.app.session()) diff --git a/onegov/gazette/layout.py b/onegov/gazette/layout.py index d696a2f..5006de2 100644 --- a/onegov/gazette/layout.py +++ b/onegov/gazette/layout.py @@ -183,10 +183,10 @@ def menu(self): active = isinstance(self.model, GazetteNoticeCollection) link = self.request.link( - GazetteNoticeCollection(self.session, state='accepted') + GazetteNoticeCollection(self.session, state='published') ) result.append(( - _("My Accepted Official Notices"), + _("My Published Official Notices"), link, active )) @@ -222,17 +222,33 @@ def format_date(self, dt, format): dt = to_timezone(dt, self.principal.time_zone) return super(Layout, self).format_date(dt, format) - def format_issue(self, issue, date_format='date'): - """ Returns the issues number and date. """ + def format_issue(self, issue, date_format='date', notice=None): + """ Returns the issues number and date and optionally the publication + number of the given notice. """ + assert isinstance(issue, Issue) - return self.request.translate(_( - "No. ${number}, ${issue_date}", - mapping={ - 'number': issue.number or '', - 'issue_date': self.format_date(issue.date, date_format) - } - )) + issue_number = issue.number or '' + issue_date = self.format_date(issue.date, date_format) + notice_number = notice.issues.get(issue.name, None) if notice else None + + if notice_number: + return self.request.translate(_( + "No. ${issue_number}, ${issue_date} / ${notice_number}", + mapping={ + 'issue_number': issue_number, + 'issue_date': issue_date, + 'notice_number': notice_number + } + )) + else: + return self.request.translate(_( + "No. ${issue_number}, ${issue_date}", + mapping={ + 'issue_number': issue_number, + 'issue_date': issue_date + } + )) def format_text(self, text): return '
'.join((text or '').splitlines()) diff --git a/onegov/gazette/locale/de_CH/LC_MESSAGES/onegov.gazette.po b/onegov/gazette/locale/de_CH/LC_MESSAGES/onegov.gazette.po index c3f2958..4d6657b 100644 --- a/onegov/gazette/locale/de_CH/LC_MESSAGES/onegov.gazette.po +++ b/onegov/gazette/locale/de_CH/LC_MESSAGES/onegov.gazette.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-12-04 15:47+0100\n" +"POT-Creation-Date: 2017-12-11 10:20+0100\n" "PO-Revision-Date: 2017-12-04 15:47+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -33,8 +33,12 @@ msgid "Show less" msgstr "Weniger anzeigen" #, python-format -msgid "No. ${number}, ${issue_date}" -msgstr "Nr. ${number}, ${issue_date}" +msgid "No. ${issue_number}, ${issue_date} / ${notice_number}" +msgstr "Nr. ${issue_number}, ${issue_date} / ${notice_number}" + +#, python-format +msgid "No. ${issue_number}, ${issue_date}" +msgstr "Nr. ${issue_number}, ${issue_date}" msgid "Official Notices" msgstr "Meldungen" @@ -60,8 +64,11 @@ msgstr "Gruppen" msgid "My Drafted and Submitted Official Notices" msgstr "Offene Meldungen" -msgid "My Accepted Official Notices" -msgstr "Angenommene Meldungen" +msgid "My Published Official Notices" +msgstr "Veröffentlichte Meldungen" + +msgid "Gazette" +msgstr "Amtsblatt" msgid "This value already exists." msgstr "Dieser Wert ist bereits vorhanden." @@ -217,18 +224,24 @@ msgstr "Kommende Ausgaben" msgid "Issue" msgstr "Ausgabe" +msgid "PDF" +msgstr "PDF" + msgid "No upcoming issues." msgstr "Keine kommenden Ausgaben." +msgid "Publish" +msgstr "Veröffentlichen" + +msgid "Generate" +msgstr "Erzeugen" + msgid "Past Issues" msgstr "Vergangene Ausgaben" msgid "No past issues." msgstr "Keine vergangenen Ausgaben." -msgid "Gazette" -msgstr "Amtsblatt" - msgid "homepage" msgstr "Startseite" @@ -472,6 +485,20 @@ msgstr "Ausgabe bearbeiten" msgid "Delete Issue" msgstr "Ausgabe löschen" +msgid "Publish all notices" +msgstr "Alle Meldungen veröffentlichen" + +#, python-format +msgid "" +"Do you really want to publish all notices of \"${item}\"? This will assign " +"the publication numbers for ${number} notice(s)." +msgstr "" +"Alle Meldungen von \"${item}\" veröffentlichen? Es werden " +"Publikationsnummern für ${number} Meldung(en) vergeben." + +msgid "Generate PDF" +msgstr "PDF erstellen" + msgid "Issue added." msgstr "Ausgabe hinzugefügt." @@ -484,6 +511,15 @@ msgstr "Es können nur unbenutzte Ausgaben gelöscht werden." msgid "Issue deleted." msgstr "Ausgabe gelöscht." +msgid "There are submitted notices for this issue!" +msgstr "Diese Ausgabe hat eingereichte Meldungen!" + +msgid "All notices published." +msgstr "Alle Meldungen veröffentlicht." + +msgid "PDF generated." +msgstr "PDF erstellt." + msgid "Edit Official Notice" msgstr "Meldung bearbeiten" @@ -499,9 +535,6 @@ msgstr "Kopieren" msgid "Preview" msgstr "Vorschau" -msgid "Publish" -msgstr "Veröffentlichen" - msgid "Reject" msgstr "Zurückweisen" @@ -578,6 +611,17 @@ msgstr "\"${item}\" zurückweisen?" msgid "Reject Official Note" msgstr "Meldung zurückweisen" +#, python-format +msgid "" +"Do you really want to publish \"${item}\"? This will assign the publication " +"numbers for the following issues: ${issues}." +msgstr "" +"\"${item}\" veröffentlichen? Es werden Publikationsnummern für die folgenden " +"Ausgaben vergeben: ${issues}." + +msgid "Publish Official Note" +msgstr "Meldung veröffentlichen" + msgid "Only drafted or rejected official notices may be submitted." msgstr "" "Es können nur zurückgewiesene Meldungen oder Meldungen in Arbeit eingereicht " @@ -598,6 +642,12 @@ msgstr "Es können nur eingereichte Meldungen zurückgewiesen werden." msgid "Official notice rejected." msgstr "Meldung zurückgewiesen." +msgid "Only accepted official notices may be published." +msgstr "Es können nur angenommene Meldungen veröffentlicht werden." + +msgid "Official notice published." +msgstr "Meldung veröffentlicht." + msgid "mail sent" msgstr "Druck beauftragt" @@ -680,6 +730,3 @@ msgstr "Benutzer gelöscht." msgid "User account created" msgstr "Benutzerkonto Amtsblattredaktion erstellt" - -#~ msgid "Do you really want to delete the attachment?" -#~ msgstr "Anhang löschen?" diff --git a/onegov/gazette/models/issue.py b/onegov/gazette/models/issue.py index b13d3cd..67e2f41 100644 --- a/onegov/gazette/models/issue.py +++ b/onegov/gazette/models/issue.py @@ -1,11 +1,16 @@ from collections import namedtuple +from onegov.core.crypto import random_token from onegov.core.orm import Base from onegov.core.orm.mixins import TimestampMixin from onegov.core.orm.types import UTCDateTime +from onegov.file import AssociatedFiles +from onegov.file import File +from onegov.file.utils import as_fileintent from sedate import as_datetime from sedate import standardize_date from sqlalchemy import Column from sqlalchemy import Date +from sqlalchemy import extract from sqlalchemy import Integer from sqlalchemy import Text from sqlalchemy_utils import observes @@ -28,7 +33,11 @@ def from_string(cls, value): return cls(*[int(part) for part in value.split('-')]) -class Issue(Base, TimestampMixin): +class IssuePdfFile(File): + __mapper_args__ = {'polymorphic_identity': 'gazette_issue'} + + +class Issue(Base, TimestampMixin, AssociatedFiles): """ Defines an issue. """ __tablename__ = 'gazette_issues' @@ -48,7 +57,22 @@ class Issue(Base, TimestampMixin): # The deadline of this issue. deadline = Column(UTCDateTime, nullable=True) - def notices(self): + @property + def pdf(self): + return self.files[0] if self.files else None + + @pdf.setter + def pdf(self, value): + filename = '{}.pdf'.format(self.name) + + pdf = self.pdf or IssuePdfFile(id=random_token()) + pdf.name = filename + pdf.reference = as_fileintent(value, filename) + + if not self.pdf: + self.files.append(pdf) + + def notices(self, state=None): """ Returns a query to get all notices related to this issue. """ from onegov.gazette.models.notice import GazetteNotice # circular @@ -57,9 +81,36 @@ def notices(self): notices = notices.filter( GazetteNotice._issues.has_key(self.name) # noqa ) + if state: + notices = notices.filter(GazetteNotice.state == state) return notices + @staticmethod + def publication_numbers(session): + """ Returns the current publication numbers by year. """ + + from onegov.gazette.models.notice import GazetteNotice # circular + from onegov.gazette.collections.issues import IssueCollection # circ. + + result = {} + + for year in IssueCollection(session).years: + numbers = [] + + issues = session.query(Issue.name) + issues = issues.filter(extract('year', Issue.date) == year) + for issue in issues: + numbers.extend([ + x[0] for x in session.query(GazetteNotice._issues[issue]) + if x[0] + ]) + numbers = [int(number) for number in numbers] + + result[year] = max(numbers) if numbers else 0 + + return result + @property def in_use(self): """ True, if the issued is used by any notice. """ @@ -91,3 +142,13 @@ def date_observer(self, date_): dates = [issues.get(issue, None) for issue in notice._issues] dates = [date for date in dates if date] notice.first_issue = min(dates) + + def publish(self, request): + """ Ensures that every accepted notice of this issue has been + published. + + """ + + publication_numbers = None + for notice in self.notices('accepted'): + publication_numbers = notice.publish(request, publication_numbers) diff --git a/onegov/gazette/models/notice.py b/onegov/gazette/models/notice.py index 6cd9209..9702d54 100644 --- a/onegov/gazette/models/notice.py +++ b/onegov/gazette/models/notice.py @@ -198,6 +198,31 @@ def accept(self, request): super(GazetteNotice, self).accept() self.add_change(request, _("accepted")) + def publish(self, request, publication_numbers=None): + """ Publish an accepted notice. + + This automatically adds en entry to the changelog and assigns the + publication numbers. + + Returns the updated publications numbers to allow batch-publishing. + + """ + + if not publication_numbers: + session = request.app.session() + publication_numbers = Issue.publication_numbers(session) + + issues = dict(self.issues) + for issue in self.issue_objects: + publication_numbers[issue.date.year] += 1 + issues[issue.name] = str(publication_numbers[issue.date.year]) + self._issues = issues + + super(GazetteNotice, self).publish() + self.add_change(request, _("published")) + + return publication_numbers + @property def rejected_comment(self): """ Returns the comment of the last rejected change log entry. """ diff --git a/onegov/gazette/models/principal.py b/onegov/gazette/models/principal.py index e4b40f0..1f97a4b 100644 --- a/onegov/gazette/models/principal.py +++ b/onegov/gazette/models/principal.py @@ -12,7 +12,8 @@ def __init__( publish_to='', publish_from='', time_zone='Europe/Zurich', - help_link='' + help_link='', + show_archive=False ): self.name = name self.logo = logo @@ -21,6 +22,7 @@ def __init__( self.publish_from = publish_from self.time_zone = time_zone self.help_link = help_link + self.show_archive = show_archive @classmethod def from_yaml(cls, yaml_source): diff --git a/onegov/gazette/path.py b/onegov/gazette/path.py index bebd6c8..51fb67c 100644 --- a/onegov/gazette/path.py +++ b/onegov/gazette/path.py @@ -1,5 +1,6 @@ from onegov.core.converters import extended_date_converter from onegov.core.converters import uuid_converter +from onegov.file.integration import get_file from onegov.gazette import GazetteApp from onegov.gazette.collections import CategoryCollection from onegov.gazette.collections import GazetteNoticeCollection @@ -11,6 +12,7 @@ from onegov.gazette.models import Organization from onegov.gazette.models import OrganizationMove from onegov.gazette.models import Principal +from onegov.gazette.models.issue import IssuePdfFile from onegov.user import Auth from onegov.user import User from onegov.user import UserCollection @@ -68,6 +70,14 @@ def get_organization(app, id): return OrganizationCollection(app.session()).by_id(id) +@GazetteApp.path( + model=OrganizationMove, + path='/move/organization/{subject_id}/{direction}/{target_id}', + converters=dict(subject_id=int, target_id=int)) +def get_page_move(app, subject_id, direction, target_id): + return OrganizationMove(app.session(), subject_id, target_id, direction) + + @GazetteApp.path(model=IssueCollection, path='/issues') def get_issues(app): return IssueCollection(app.session()) @@ -78,12 +88,11 @@ def get_issue(app, id): return IssueCollection(app.session()).by_id(id) -@GazetteApp.path( - model=OrganizationMove, - path='/move/organization/{subject_id}/{direction}/{target_id}', - converters=dict(subject_id=int, target_id=int)) -def get_page_move(app, subject_id, direction, target_id): - return OrganizationMove(app.session(), subject_id, target_id, direction) +@GazetteApp.path(model=IssuePdfFile, path='/pdf/{name}') +def get_issue_pdf(request, app, name): + issue = IssueCollection(app.session()).by_name(name.replace('.pdf', '')) + if issue and issue.pdf: + return get_file(app, issue.pdf.id) @GazetteApp.path( diff --git a/onegov/gazette/pdf.py b/onegov/gazette/pdf.py new file mode 100644 index 0000000..54662ad --- /dev/null +++ b/onegov/gazette/pdf.py @@ -0,0 +1,186 @@ +from copy import deepcopy +from io import BytesIO +from onegov.gazette import _ +from onegov.gazette.layout import Layout +from onegov.gazette.models import Category +from onegov.gazette.models import GazetteNotice +from onegov.gazette.models import Organization +from onegov.pdf import page_fn_footer +from onegov.pdf import page_fn_header_and_footer +from onegov.pdf import Pdf as PdfBase + + +class Pdf(PdfBase): + + def adjust_style(self, font_size=10): + """ Adds styles for notices. """ + + super(Pdf, self).adjust_style(font_size) + + self.style.title = deepcopy(self.style.normal) + self.style.title.fontSize = 2.25 * self.style.fontSize + self.style.title.leading = 1.2 * self.style.title.fontSize + self.style.title.spaceBefore = 0 + self.style.title.spaceAfter = 0.67 * self.style.title.fontSize + + self.style.h_notice = deepcopy(self.style.normal) + self.style.h_notice.fontSize = 1.125 * self.style.fontSize + self.style.h_notice.spaceBefore = 1.275 * self.style.h_notice.fontSize + self.style.h_notice.spaceAfter = 0.275 * self.style.h_notice.fontSize + + self.style.paragraph.spaceAfter = 0.675 * self.style.paragraph.fontSize + self.style.paragraph.leading = 1.275 * self.style.paragraph.fontSize + + self.style.ul_bullet = '-' + self.style.li.spaceAfter = 0.275 * self.style.li.fontSize + self.style.li.leading = 1.275 * self.style.li.fontSize + + def h(self, title, level=0): + """ Adds a title according to the given level. """ + + if not level: + self.p_markup(title, self.style.title) + else: + getattr(self, 'h{}'.format(min(level, 4)))(title) + + def unfold_data(self, data, level=1): + """ Take a nested list of dicts and add it. """ + + for item in data: + title = item.get('title', None) + if title: + self.h(title, level) + self.story[-1].keepWithNext = True + + notices = item.get('notices', []) + for notice in notices: + self.p_markup( + '{} {}'.format( + notice[0], + 0.875 * self.style.h_notice.fontSize, + notice[2] + ), + self.style.h_notice + ) + self.story[-1].keepWithNext = True + self.mini_html(notice[1]) + + children = item.get('children', []) + if children: + self.unfold_data(children, level + 1) + + @staticmethod + def query_notices(session, issue, organization, category): + """ Queries all notices with the given values, ordered by publication + number. + + """ + + notices = session.query( + GazetteNotice.title, + GazetteNotice.text, + GazetteNotice._issues[issue] + ) + notices = notices.filter( + GazetteNotice._issues.has_key(issue), # noqa + GazetteNotice.state == 'published', + GazetteNotice._organizations.has_key(organization), + GazetteNotice._categories.has_key(category) + ) + notices = notices.order_by( + GazetteNotice._issues[issue] + ) + return notices.all() + + @classmethod + def from_issue(cls, issue, request, file=None): + """ Generate a PDF for one issue. """ + + # Collect the data + data = [] + + session = request.app.session() + + used_categories = session.query(GazetteNotice._categories.keys()) + used_categories = used_categories.filter( + GazetteNotice._issues.has_key(issue.name), # noqa + GazetteNotice.state == 'published', + ) + used_categories = [cat[0][0] for cat in used_categories] + + used_organizations = session.query(GazetteNotice._organizations.keys()) + used_organizations = used_organizations.filter( + GazetteNotice._issues.has_key(issue.name), # noqa + GazetteNotice.state == 'published', + ) + used_organizations = [cat[0][0] for cat in used_organizations] + + if used_categories and used_organizations: + categories = session.query(Category) + categories = categories.filter(Category.name.in_(used_categories)) + categories = categories.order_by(Category.order).all() + + roots = session.query(Organization).filter_by(parent_id=None) + roots = roots.order_by(Organization.order) + + for root in roots: + root_data = [] + if not root.children: + for category in categories: + notices = cls.query_notices( + session, issue.name, root.name, category.name + ) + if notices: + root_data.append({ + 'title': category.title, + 'notices': notices + }) + else: + for child in root.children: + if child.name not in used_organizations: + continue + child_data = [] + for category in categories: + notices = cls.query_notices( + session, issue.name, child.name, category.name + ) + if notices: + child_data.append({ + 'title': category.title, + 'notices': notices + }) + if child_data: + root_data.append({ + 'title': child.title, + 'children': child_data + }) + if root_data: + data.append({ + 'title': root.title, + 'children': root_data + }) + + # Generate the PDF + layout = Layout(None, request) + title = '{} {}'.format( + request.translate(_("Gazette")), + layout.format_issue(issue, date_format='date') + ) + + file = file or BytesIO() + pdf = cls( + file, + title=title, + author=request.app.principal.name + ) + pdf.init_a4_portrait( + page_fn=page_fn_footer, + page_fn_later=page_fn_header_and_footer + ) + pdf.h(title) + pdf.unfold_data(data) + pdf.generate() + + file.seek(0) + + return file diff --git a/onegov/gazette/templates/archive.pt b/onegov/gazette/templates/archive.pt new file mode 100644 index 0000000..36b6f73 --- /dev/null +++ b/onegov/gazette/templates/archive.pt @@ -0,0 +1,18 @@ +
+ + + + + +

${year}

+ +
+
+
+ +
+
diff --git a/onegov/gazette/templates/issues.pt b/onegov/gazette/templates/issues.pt index 3787ef6..7beef12 100644 --- a/onegov/gazette/templates/issues.pt +++ b/onegov/gazette/templates/issues.pt @@ -27,6 +27,7 @@ Issue Date Deadline + PDF Actions @@ -39,11 +40,20 @@ ${issue.name} ${layout.format_date(issue.date, 'date')} ${layout.format_date(issue.deadline, 'datetime_with_weekday')} + + ${issue.pdf.name} +