diff --git a/accounts/forms.py b/accounts/forms.py index 79f86a3..de72ab9 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -16,7 +16,7 @@ # from django.contrib.auth.models import User -from django.forms import ModelChoiceField +from django.forms import ModelChoiceField, BooleanField from registration.forms import RegistrationFormUniqueEmail from django.utils.translation import ugettext_lazy as _ from recaptcha_django import ReCaptchaField @@ -109,3 +109,13 @@ def __init__(self, *args, **kwargs): super(RemoveUserOpenIDForm, self).__init__(*args, **kwargs) self.fields['openid'] = UserOpenIDChoiceField(open_ids, required=True, label=_('Delete OpenID account')) + +class ResetUserForm(forms.Form): + def __init__(self, *args, **kwargs): + super(ResetUserForm, self).__init__(*args, **kwargs) + self.fields['confirm_reset'] = BooleanField(required=True, label=_('Confirm account reset')) + +class DeleteUserForm(forms.Form): + def __init__(self, *args, **kwargs): + super(DeleteUserForm, self).__init__(*args, **kwargs) + self.fields['confirm_delete'] = BooleanField(required=True, label=_('Confirm account deletion')) diff --git a/accounts/templates/accounts/preferences.html b/accounts/templates/accounts/preferences.html index 7a322a6..6056213 100644 --- a/accounts/templates/accounts/preferences.html +++ b/accounts/templates/accounts/preferences.html @@ -8,19 +8,27 @@ // clear the password form $(document).ready(function() { $('.input-form input[type="password"]').val("") + + $(".reset-confirm").hide(); + $(".delete-confirm").hide(); + + $("#reset-btn").bind("click", function(evt) { + $(".reset-confirm").fadeIn(); + $(this).unbind("click"); + evt.preventDefault(); + }); + + $("#delete-btn").bind("click", function(evt) { + $(".delete-confirm").fadeIn(); + $(this).unbind("click"); + evt.preventDefault(); + }); }); {% endblock %} {% block content %} -{% if messages %} -{% for message in messages %} -{{ message }} -
-{% endfor %} -{% endif %} -

{% trans "Preferences" %}

{% if user.has_usable_password %}

{% trans "Change your password" %}

@@ -94,5 +102,53 @@

{% trans "OpenID Accounts" %}

+

{% trans "Export Notes" %}

+ + + + + + +
{% trans "JSON" %}{% trans "XML" %}{% trans "Tarball" %}
+ + +

{% trans "Reset All Data" %}

+
{% trans "Warning: This action is irreversible! All of your synced notes and notebooks will be destroyed!" %}
+
+ + + {{ reset_form.as_table }} + + + + + + + +
+ +
+ +
+ +

{% trans "Permanently Delete This User" %}

+
{% trans "Warning: This action is irreversible! Any data associated with your account will be destroyed!" %}
+
+ + + {{ delete_form.as_table }} + + + + + + + +
+ +
+ +
+ {#

{% trans "Registered Applications" %}

#} {% endblock %} diff --git a/accounts/urls.py b/accounts/urls.py index 281b554..374fad5 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -27,6 +27,8 @@ import snowy.accounts.views import django_openid_auth.views +import snowy.export.views + urlpatterns = patterns('', url(r'^preferences/$', 'snowy.accounts.views.accounts_preferences', name='preferences'), diff --git a/accounts/views.py b/accounts/views.py index 9a50abf..b3f5421 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -17,7 +17,7 @@ from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth import get_backends +from django.contrib.auth import get_backends, logout from django.contrib.auth.forms import UserChangeForm, PasswordChangeForm from django.contrib.auth.models import User from django.contrib.auth import authenticate, login, REDIRECT_FIELD_NAME @@ -32,7 +32,8 @@ from django.conf import settings from snowy.accounts.models import UserProfile -from snowy.accounts.forms import InternationalizationForm, OpenIDRegistrationFormUniqueUser, EmailChangeForm, RemoveUserOpenIDForm +from snowy.accounts.forms import InternationalizationForm, OpenIDRegistrationFormUniqueUser, EmailChangeForm, RemoveUserOpenIDForm, DeleteUserForm, ResetUserForm +from snowy.core.utils import create_uuid from django_openid_auth import auth from django_openid_auth.auth import OpenIDBackend @@ -162,11 +163,36 @@ def accounts_preferences(request, template_name='accounts/preferences.html'): openid_form.cleaned_data['openid'].delete() else: openid_form = RemoveUserOpenIDForm(open_ids=open_ids) + + if 'reset_form' in request.POST: + reset_form = ResetUserForm(request.POST) + if reset_form.is_valid(): + request.user.note_set.all().delete() + request.user.notetag_set.all().delete() + + profile = request.user.userprofile_set.all()[0] + profile.latest_sync_rev = -1 + profile.current_sync_uuid = create_uuid() + profile.save() + + messages.add_message(request, messages.SUCCESS, _("User data reset")) + reset_form = ResetUserForm() + + if 'delete_form' in request.POST: + delete_form = DeleteUserForm(request.POST) + if delete_form.is_valid(): + user.delete() + messages.add_message(request, messages.SUCCESS, _("Account deleted")) + logout(request) + return HttpResponseRedirect(reverse('snowy_index')) + delete_form = DeleteUserForm() return render_to_response(template_name, {'user': user, 'i18n_form': i18n_form, 'password_form': password_form, 'email_form' : email_form, 'open_ids' : open_ids, - 'openid_form' : openid_form}, + 'openid_form' : openid_form, + 'reset_form' : reset_form, + 'delete_form' : delete_form}, context_instance=RequestContext(request)) diff --git a/export/__init__.py b/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/export/models.py b/export/models.py new file mode 100644 index 0000000..e69de29 diff --git a/export/tests.py b/export/tests.py new file mode 100644 index 0000000..1ba1e31 --- /dev/null +++ b/export/tests.py @@ -0,0 +1,95 @@ +# +# Copyright (c) 2010 Tony Young +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from django.test import TestCase + +import snowy.export.views + +import datetime +import re +import tarfile +from xml.dom.minidom import parseString, parse + +try: + from cStringIO import StringIO +except: + from StringIO import StringIO + +class fake_request(object): + class user: + @staticmethod + def is_authenticated(): + return True + +class ExportTest(TestCase): + def setUp(self): + # monkey patch snowy.export._get_data to return what we want + self.data = [ + { + "guid" : "00000000-0000-0000-0000-000000000000", + "note-content": u"here is a note with random tags and unicode", + "open-on-startup": True, + "last-metadata-change-date": "1970-01-01T13:00:00Z", + "title": u"note 壱", + "tags": [ u"herë", "are", "some", "tags" ], + "create-date": "1970-01-01T13:00:00Z", + "last-change-date": "1970-01-01T13:00:00Z" + }, + { + "guid" : "00000000-0000-0000-0000-000000000001", + "note-content": u"here is another note with äçcèñts", + "open-on-startup": False, + "last-metadata-change-date": "1970-01-01T13:00:00Z", + "title": u"ノート 2", + "tags": [ "here", "are", "some", "tags", "too" ], + "create-date": "1970-01-01T13:00:00Z", + "last-change-date": "1970-01-01T13:00:00Z" + } + ] + + self.grouped_data = dict([ + (note["guid"], note) for note in self.data + ]) + + def _get_data(request): + return self.data + snowy.export.views._get_data = _get_data + + def _assert_xml(self, note_node, data): + tag_wrap_expr = re.compile(r"\<.+?\>(.*)\", re.MULTILINE | re.DOTALL) + + guid = note_node.getAttribute("guid") + for child_node in note_node.childNodes: + tag = child_node.tagName + + content = child_node.toxml() + content = tag_wrap_expr.match(content).group(1) + + if tag == "text": + self.assertEquals(tag_wrap_expr.match(child_node.childNodes[0].toxml()).group(1), "%s\n\n%s" % (data["title"], data["note-content"])) + elif tag == "tags": + self.assertEquals([ tag.childNodes[0].toxml() for tag in child_node.childNodes ], data["tags"]) + elif tag == "open-on-startup": + self.assertEquals(content == "True" and True or False, data[tag]) + else: + self.assertEquals(content, data[tag]) + + def test_tar_export(self): + data = tarfile.TarFile(fileobj=StringIO(snowy.export.views.export_tar(fake_request).content), mode="r") + for info in data: + doc = parse(data.extractfile(info.name)) + self._assert_xml(doc.childNodes[0], self.grouped_data[info.name.split(".")[0]]) diff --git a/export/urls.py b/export/urls.py new file mode 100644 index 0000000..0d756da --- /dev/null +++ b/export/urls.py @@ -0,0 +1,24 @@ +# +# Copyright (c) 2010 Tony Young +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from django.conf.urls.defaults import * + +import snowy.export.views + +urlpatterns = patterns('', + url(r'^tar', snowy.export.views.export_tar, name='export-tar'), +) diff --git a/export/views.py b/export/views.py new file mode 100644 index 0000000..a022b1b --- /dev/null +++ b/export/views.py @@ -0,0 +1,123 @@ +# +# Copyright (c) 2010 Tony Young +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from django.http import HttpResponse + +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required + +from snowy.api.handlers import describe_note +from snowy.notes.models import Note + +import tarfile + +import time +from dateutil.parser import parse as parse_iso_time + +from xml.dom.minidom import Document, parseString + +try: + from cStringIO import StringIO +except: + from StringIO import StringIO + +# list of allowed fields +ALLOWED_FIELDS = [ "title", "note-content", "last-change-date", "last-metadata-change-date", "create-date", "tags", "open-on-startup" ] + +def _get_data(request): + notes = Note.objects.user_viewable(request.user, User.objects.get(username=request.user)) + return [describe_note(n) for n in notes] + +def _note_to_xml(doc, note): + root = doc.createElement("note") + root.setAttribute("xmlns", "http://beatniksoftware.com/tomboy") + root.setAttribute("xmlns:link", "http://beatniksoftware.com/tomboy/link") + root.setAttribute("xmlns:size", "http://beatniksoftware.com/tomboy/size") + root.setAttribute("version", "0.3") + + for field in ALLOWED_FIELDS: + if field == "note-content": + wrap_elem = doc.createElement("text") + wrap_elem.setAttribute("xml:space", "preserve") + + # make expat parse nicely + subdoc = parseString('%s\n\n%s' % ( + note["title"].encode("utf-8"), + note[field].encode("utf-8") + )) + + elem = subdoc.documentElement + + # quietly get rid of temporary namespaces + elem.removeAttribute("xmlns:link") + elem.removeAttribute("xmlns:size") + + elem.setAttribute("version", "0.1") + + wrap_elem.appendChild(elem) + elem = wrap_elem + else: + elem = doc.createElement(field) + + if field == "tags": + for tag in note[field]: + tag_elem = doc.createElement("tag") + tag_elem.appendChild(doc.createTextNode(tag)) + elem.appendChild(tag_elem) + else: + content = note[field] + if not isinstance(content, unicode): + content = str(content) + elem.appendChild(doc.createTextNode(content)) + + root.appendChild(elem) + + return root + +@login_required +def export_tar(request): + data = _get_data(request) + + arch_file = StringIO() + arch = tarfile.TarFile(fileobj=arch_file, mode="w") + + for note in data: + doc = Document() + root = _note_to_xml(doc, note) + doc.appendChild(root) + + note_data = doc.toxml(encoding='utf-8') + + note_file = StringIO() + note_file.write(note_data) + note_file.seek(0) + + note_info = tarfile.TarInfo("%s.note" % note["guid"]) + note_info.size = len(note_data) + note_info.mtime = time.mktime(parse_iso_time(note["last-change-date"]).timetuple()) + + arch.addfile( + tarinfo=note_info, + fileobj=note_file + ) + + arch.close() + + response = HttpResponse(arch_file.getvalue()) + response["Content-Type"] = "application/x-tar" + response["Content-Disposition"] = "attachment; filename=snowy-%s-%s.tar" % (request.user, time.strftime("%Y-%m-%d")) + return response diff --git a/settings.py b/settings.py index e21dc06..6c5ccc0 100644 --- a/settings.py +++ b/settings.py @@ -127,6 +127,7 @@ 'django_openid_auth', 'notes', 'mobile_notes', + 'export', # System apps 'django.contrib.admin', diff --git a/templates/base.html b/templates/base.html index dbb19be..3d7b1ea 100644 --- a/templates/base.html +++ b/templates/base.html @@ -58,6 +58,12 @@

{% endblock %} + {% if messages %} + {% for message in messages %} + {{ message }} +
+ {% endfor %} + {% endif %} {% block content %} {% endblock %} diff --git a/urls.py b/urls.py index 923cfb7..79544d1 100644 --- a/urls.py +++ b/urls.py @@ -33,6 +33,8 @@ (r'^admin/doc/', include('django.contrib.admindocs.urls')), (r'^admin/', include(admin.site.urls)), + (r'^export/', include('snowy.export.urls')), + (r'^mobile/', include('snowy.mobile_notes.urls')), url(r'^(?P\w+)/$', 'snowy.views.user_index', name="user_index"),