-
Notifications
You must be signed in to change notification settings - Fork 53
Draft model and autosave on Project edit form #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 59 commits
fd29b97
5e0c866
29ad207
468cc92
e20f3f0
86b5449
6db29ca
45cc1ec
21c55fe
3404dc3
2ba252a
13bb27f
2009366
4e3f037
e32971e
cbae311
22cae87
7154dd2
acdb758
beaa65f
bc25e3e
f1fe677
94f25bb
07b1c5f
c2d1bc0
4d31f12
d25d236
51ac07f
daf4270
5df6d23
3a7b9f2
34a5295
7f3ae6a
51e6dfd
ae27841
059430d
35715f2
2e773ed
e8497a1
8013eb2
7d460d3
193416a
2df3296
e075640
2ec4fb8
3d2b35f
a9f1933
3bf1d4e
aadd9f8
a0b660e
90a12ff
03be511
49e91eb
dea2a95
96af04a
b703846
82f87da
e5050fd
dd6499c
7866c6e
2ab6a44
9f1c3f5
5bb58c6
91f3d93
30d0b2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
# -*- coding: utf-8 -*- | ||
|
||
from sqlalchemy_utils import UUIDType | ||
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)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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,75 @@ def loader(self, profile, project, section): | |
).first_or_404() | ||
g.profile = section.project.profile | ||
return section | ||
|
||
|
||
class DraftModelViewMixin(object): | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
def get_draft(self, current_obj=None): | ||
""" | ||
Returns the draft object for `current_obj`. Defaults to `self.obj`. | ||
`current_obj` is needed in case of multi-model views. | ||
""" | ||
obj = current_obj or self.obj | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return Draft.query.get((self.model.__tablename__, obj.uuid)) | ||
|
||
def delete_draft(self, current_obj=None): | ||
""" | ||
Deletes draft for `current_obj`, or `self.obj` if `current_obj` is `None`. | ||
""" | ||
draft = self.get_draft(current_obj) | ||
if draft is not None: | ||
db.session.delete(draft) | ||
db.session.commit() | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def autosave_get(self, current_obj=None): | ||
""" | ||
Returns a tuple of the current draft revision and the formdata needed to initialize forms | ||
""" | ||
draft = self.get_draft(current_obj) | ||
initial_formdata = MultiDict(draft.body['form']) if draft is not None else None | ||
return draft.revision, initial_formdata | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def autosave_post(self, current_obj=None): | ||
""" | ||
Handles autosave POST requests | ||
""" | ||
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 {'error': _("Form must contain a revision ID.")}, 400 | ||
if forms.Form().validate_on_submit(): | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
incoming_data = MultiDict(request.form.items(multi=True)) | ||
client_revision = incoming_data.pop('form.revision') | ||
incoming_data.pop('csrf_token') | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# find the last draft | ||
draft = self.get_draft(current_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 {'error': _("There has been changes to this draft since you last edited it. Please reload.")}, 400 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There have been changes to this item since you last edited it. Please reload this page. |
||
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 = MultiDict(draft.body['form']) | ||
for key in incoming_data.keys(): | ||
if existing[key] != incoming_data[key]: | ||
existing[key] = incoming_data[key] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not required. You can simply do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that doesn't work as |
||
draft.body = {'form': existing} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Set There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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 {'error': _("Invalid revision ID or the existing changes have been submitted already. Please reload.")}, 400 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can happen in the following scenario:
A variation of this happens if step 2 is skipped. Tab A will now make changes on an obsolete document, losing Tab B's changes. No error will be displayed. To fix this we have to track revision id of not just the draft, but the source document as well. This deserves to be a separate ticket since it's some way off from our original intent of providing autosave functionality. |
||
else: | ||
# no draft exists, create one | ||
obj = current_obj or self.obj | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
draft = Draft( | ||
table=Project.__tablename__, table_row_id=obj.uuid, | ||
body={'form': incoming_data}, revision=uuid4() | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
) | ||
db.session.add(draft) | ||
db.session.commit() | ||
return {'revision': draft.revision} | ||
else: | ||
return {'error': _("Invalid CSRF token")}, 401 | ||
iambibhas marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,22 +1,25 @@ | ||
# -*- coding: utf-8 -*- | ||
|
||
import unicodecsv | ||
from uuid import uuid4 | ||
from cStringIO import StringIO | ||
from flask import g, flash, redirect, Response, request, abort, current_app | ||
from werkzeug.datastructures import MultiDict | ||
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 | ||
from ..models import db, Project, Section, Proposal, Rsvp, RSVP_STATUS | ||
from ..models import db, Project, Section, Proposal, Rsvp, Draft, RSVP_STATUS | ||
from ..forms import ProjectForm, SubprojectForm, RsvpForm, ProjectTransitionForm, ProjectBoxofficeForm | ||
from ..jobs import tag_locations, import_tickets | ||
from .proposal import proposal_headers, proposal_data, proposal_data_flat | ||
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, DraftModelViewMixin | ||
from .decorators import legacy_redirect | ||
|
||
|
||
|
@@ -75,7 +78,7 @@ class FunnelProfileProjectView(ProfileProjectView): | |
|
||
|
||
@route('/<profile>/<project>/') | ||
class ProjectView(ProjectViewMixin, UrlForView, ModelView): | ||
class ProjectView(ProjectViewMixin, DraftModelViewMixin, UrlForView, ModelView): | ||
__decorators__ = [legacy_redirect] | ||
|
||
@route('') | ||
|
@@ -129,23 +132,45 @@ 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.autosave_get() | ||
|
||
# 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_app.config.get('TIMEZONE') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrelated fix, but this should default to the user's timezone, not the app's. Use |
||
|
||
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 | ||
self.delete_draft() | ||
|
||
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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') |
Uh oh!
There was an error while loading. Please reload this page.