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')