diff --git a/funnel/forms/project.py b/funnel/forms/project.py index d850e369a..489e067ba 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -7,7 +7,7 @@ from baseframe import __ import baseframe.forms as forms from baseframe.forms.sqlalchemy import AvailableName, QuerySelectField -from ..models import RSVP_STATUS +from ..models import RSVP_STATUS, Project __all__ = [ 'EventForm', 'ProjectForm', 'ProjectTransitionForm', 'RsvpForm', @@ -76,6 +76,8 @@ def set_queries(self): self.admin_team.query = profile_teams self.review_team.query = profile_teams self.checkin_team.query = profile_teams + self.parent_project.query = Project.query.filter( + Project.profile == self.edit_obj.profile, Project.id != self.edit_obj.id, Project.parent_project == None) # NOQA def validate_bg_color(self, field): if not valid_color_re.match(field.data): diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index 8ba443cb1..831c7ae92 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -3,19 +3,20 @@ from coaster.sqlalchemy import (TimestampMixin, UuidMixin, BaseMixin, BaseNameMixin, BaseScopedNameMixin, BaseScopedIdNameMixin, BaseIdNameMixin, MarkdownColumn, - JsonDict, CoordinatesMixin, make_timestamp_columns) + JsonDict, NoIdMixin, CoordinatesMixin, make_timestamp_columns) from coaster.db import db -from .user import * -from .profile import * from .commentvote import * +from .contact_exchange import * +from .draft import * +from .event import * +from .feedback import * +from .profile import * from .project import * -from .section import * -from .usergroup import * from .proposal import * -from .feedback import * +from .rsvp import * +from .section import * from .session import * +from .user import * +from .usergroup import * from .venue import * -from .rsvp import * -from .event import * -from .contact_exchange import * diff --git a/funnel/models/draft.py b/funnel/models/draft.py new file mode 100644 index 000000000..32d7c5ce7 --- /dev/null +++ b/funnel/models/draft.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +from sqlalchemy_utils import UUIDType +from werkzeug.datastructures import MultiDict +from . import db, JsonDict, NoIdMixin + +__all__ = ['Draft'] + + +class Draft(NoIdMixin, db.Model): + """Store for autosaved, unvalidated drafts on behalf of other models""" + __tablename__ = 'draft' + + table = db.Column(db.UnicodeText, primary_key=True) + table_row_id = db.Column(UUIDType(binary=False), primary_key=True) + body = db.Column(JsonDict, nullable=False, server_default='{}') + revision = db.Column(UUIDType(binary=False)) + + @property + def formdata(self): + return MultiDict(self.body.get('form', {})) + + @formdata.setter + def formdata(self, value): + if self.body is not None: + self.body['form'] = value + else: + self.body = {'form': value} diff --git a/funnel/templates/formlayout.html.jinja2 b/funnel/templates/formlayout.html.jinja2 index bfba48771..6ff9c620a 100644 --- a/funnel/templates/formlayout.html.jinja2 +++ b/funnel/templates/formlayout.html.jinja2 @@ -9,6 +9,17 @@ {%- endassets -%} {% endblock %} +{% block contentwrapper %} +
+
+ {%- if autosave %} +

+ {% endif %} + {% block content %}{% endblock %} +
+
+{% endblock %} + {% block pagescripts %} {% assets "js_codemirrormarkdown" -%} @@ -17,3 +28,89 @@ {%- endassets -%} {% endblock %} + +{% block layoutscripts %} + {%- if autosave %} + + {%- endif %} + {% block footerscripts %}{% endblock %} +{% endblock %} + + + diff --git a/funnel/views/mixins.py b/funnel/views/mixins.py index 1bece070c..05b3d9b0a 100644 --- a/funnel/views/mixins.py +++ b/funnel/views/mixins.py @@ -1,7 +1,10 @@ +from uuid import uuid4 from flask import abort, g, redirect, request +from baseframe import _, forms from coaster.utils import require_one_of -from ..models import (Project, Profile, ProjectRedirect, Proposal, ProposalRedirect, Session, - UserGroup, Venue, VenueRoom, Section) +from werkzeug.datastructures import MultiDict +from ..models import (Draft, Project, Profile, ProjectRedirect, Proposal, ProposalRedirect, Session, + UserGroup, Venue, VenueRoom, Section, db) class ProjectViewMixin(object): @@ -130,3 +133,80 @@ def loader(self, profile, project, section): ).first_or_404() g.profile = section.project.profile return section + + +class DraftViewMixin(object): + def get_draft(self, obj=None): + """ + Returns the draft object for `obj`. Defaults to `self.obj`. + `obj` is needed in case of multi-model views. + """ + obj = obj if obj is not None else self.obj + return Draft.query.get((self.model.__tablename__, obj.uuid)) + + def delete_draft(self, obj=None): + """ + Deletes draft for `obj`, or `self.obj` if `obj` is `None`. + """ + draft = self.get_draft(obj) + if draft is not None: + db.session.delete(draft) + else: + raise ValueError(_("There is no draft for the given object.")) + + def get_draft_data(self, obj=None): + """ + Returns a tuple of the current draft revision and the formdata needed to initialize forms + """ + draft = self.get_draft(obj) + if draft is not None: + return draft.revision, draft.formdata + else: + return None, None + + def autosave_post(self, obj=None): + """ + Handles autosave POST requests + """ + obj = obj if obj is not None else self.obj + if 'form.revision' not in request.form: + # as form.autosave is true, the form should have `form.revision` field even if it's empty + return {'status': 'error', 'error_identifier': 'form_missing_revision_field', 'error_description': _("Form must contain a revision ID.")}, 400 + + # CSRF check + if forms.Form().validate_on_submit(): + incoming_data = MultiDict(request.form.items(multi=True)) + client_revision = incoming_data.pop('form.revision') + incoming_data.pop('csrf_token', None) + + # find the last draft + draft = self.get_draft(obj) + + if draft is not None: + if client_revision is None or (client_revision is not None and str(draft.revision) != client_revision): + # draft exists, but the form did not send a revision ID, + # OR revision ID sent by client does not match the last revision ID + return {'status': 'error', 'error_identifier': 'missing_or_invalid_revision', 'error_description': _("There have been changes to this draft since you last edited it. Please reload.")}, 400 + elif client_revision is not None and str(draft.revision) == client_revision: + # revision ID sent my client matches, save updated draft data and update revision ID + existing = draft.formdata + for key in incoming_data.keys(): + if existing[key] != incoming_data[key]: + existing[key] = incoming_data[key] + draft.formdata = existing + draft.revision = uuid4() + elif draft is None and client_revision: + # The form contains a revision ID but no draft exists. + # Somebody is making autosave requests with an invalid draft ID. + return {'status': 'error', 'error_identifier': 'invalid_or_expired_revision', 'error_description': _("Invalid revision ID or the existing changes have been submitted already. Please reload.")}, 400 + else: + # no draft exists, create one + draft = Draft( + table=Project.__tablename__, table_row_id=obj.uuid, + formdata=incoming_data, revision=uuid4() + ) + db.session.add(draft) + db.session.commit() + return {'revision': draft.revision} + else: + return {'status': 'error', 'error_identifier': 'invalid_csrf', 'error_description': _("Invalid CSRF token")}, 400 diff --git a/funnel/views/project.py b/funnel/views/project.py index 90c62b6d0..bcbd3a6e8 100644 --- a/funnel/views/project.py +++ b/funnel/views/project.py @@ -6,6 +6,7 @@ from baseframe import _, forms from baseframe.forms import render_form from coaster.auth import current_auth +from coaster.utils import getbool from coaster.views import jsonp, route, render_with, requires_permission, UrlForView, ModelView from .. import app, funnelapp, lastuser @@ -16,7 +17,7 @@ from .schedule import schedule_data from .venue import venue_data, room_data from .section import section_data -from .mixins import ProjectViewMixin, ProfileViewMixin +from .mixins import ProjectViewMixin, ProfileViewMixin, DraftViewMixin from .decorators import legacy_redirect @@ -75,7 +76,7 @@ class FunnelProfileProjectView(ProfileProjectView): @route('///') -class ProjectView(ProjectViewMixin, UrlForView, ModelView): +class ProjectView(ProjectViewMixin, DraftViewMixin, UrlForView, ModelView): __decorators__ = [legacy_redirect] @route('') @@ -129,23 +130,47 @@ def csv(self): headers=[('Content-Disposition', 'attachment;filename="{project}.csv"'.format(project=self.obj.name))]) @route('edit', methods=['GET', 'POST']) + @render_with(json=True) @lastuser.requires_login @requires_permission('edit_project') def edit(self): - if self.obj.parent_project: - form = SubprojectForm(obj=self.obj, model=Project) - else: - form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project) - form.parent_project.query = Project.query.filter(Project.profile == self.obj.profile, Project.id != self.obj.id, Project.parent_project == None) # NOQA - if request.method == 'GET' and not self.obj.timezone: - form.timezone.data = current_app.config.get('TIMEZONE') - if form.validate_on_submit(): - form.populate_obj(self.obj) - db.session.commit() - flash(_("Your changes have been saved"), 'info') - tag_locations.queue(self.obj.id) - return redirect(self.obj.url_for(), code=303) - return render_form(form=form, title=_("Edit project"), submit=_("Save changes")) + if request.method == 'GET': + # find draft if it exists + draft_revision, initial_formdata = self.get_draft_data() + + # initialize forms with draft initial formdata. + # if no draft exists, initial_formdata is None. wtforms ignore formdata if it's None. + if self.obj.parent_project: + form = SubprojectForm(obj=self.obj, model=Project, formdata=initial_formdata) + else: + form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project, formdata=initial_formdata) + + if not self.obj.timezone: + form.timezone.data = current_auth.user.timezone + + return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True, draft_revision=draft_revision) + elif request.method == 'POST': + if getbool(request.args.get('form.autosave')): + return self.autosave_post() + else: + if self.obj.parent_project: + form = SubprojectForm(obj=self.obj, model=Project) + else: + form = ProjectForm(obj=self.obj, parent=self.obj.profile, model=Project) + if form.validate_on_submit(): + form.populate_obj(self.obj) + db.session.commit() + flash(_("Your changes have been saved"), 'info') + tag_locations.queue(self.obj.id) + + # find and delete draft if it exists + if self.get_draft() is not None: + self.delete_draft() + db.session.commit() + + return redirect(self.obj.url_for(), code=303) + else: + return render_form(form=form, title=_("Edit project"), submit=_("Save changes"), autosave=True) @route('boxoffice_data', methods=['GET', 'POST']) @lastuser.requires_login diff --git a/migrations/versions/94ce3a9b7a3a_draft_model.py b/migrations/versions/94ce3a9b7a3a_draft_model.py new file mode 100644 index 000000000..d161cb861 --- /dev/null +++ b/migrations/versions/94ce3a9b7a3a_draft_model.py @@ -0,0 +1,32 @@ +"""draft model + +Revision ID: 94ce3a9b7a3a +Revises: c3069d33419a +Create Date: 2019-02-06 20:48:34.700795 + +""" + +revision = '94ce3a9b7a3a' +down_revision = 'a9cb0e1c52ed' + +from alembic import op +import sqlalchemy as sa + +from sqlalchemy_utils.types.uuid import UUIDType +from coaster.sqlalchemy.columns import JsonDict + + +def upgrade(): + op.create_table('draft', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('table', sa.UnicodeText(), nullable=False), + sa.Column('table_row_id', UUIDType(binary=False), nullable=False), + sa.Column('body', JsonDict(), server_default='{}', nullable=False), + sa.Column('revision', UUIDType(binary=False), nullable=True), + sa.PrimaryKeyConstraint('table', 'table_row_id') + ) + + +def downgrade(): + op.drop_table('draft')