aboutsummaryrefslogtreecommitdiffstats
path: root/gitrefinery/views.py
diff options
context:
space:
mode:
Diffstat (limited to 'gitrefinery/views.py')
-rw-r--r--gitrefinery/views.py691
1 files changed, 691 insertions, 0 deletions
diff --git a/gitrefinery/views.py b/gitrefinery/views.py
new file mode 100644
index 0000000..9dfa36f
--- /dev/null
+++ b/gitrefinery/views.py
@@ -0,0 +1,691 @@
+# git-refinery-web - view definitions
+#
+# Copyright (C) 2014-2018 Intel Corporation
+#
+# Licensed under the MIT license, see COPYING.MIT for details
+
+import os
+import shutil
+from collections import namedtuple
+from datetime import datetime
+import re
+import git
+
+from django.contrib.messages.views import SuccessMessageMixin
+from django.views.decorators.cache import never_cache
+from django.contrib.auth.hashers import make_password
+from django.shortcuts import get_object_or_404, render
+from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, Http404, HttpResponseBadRequest
+from django.urls import reverse, reverse_lazy, resolve
+from django.core.exceptions import PermissionDenied
+from django.template import RequestContext
+from gitrefinery.models import Repository, Release, Commit, Category, CommitCategory, CategorisationRule, Author, AuthorGroup, AuthorGroupMatchRule, AuthorGroupMembership, StatsChart, UserProfile, SecurityQuestion, SecurityQuestionAnswer
+from django.views.generic import TemplateView, DetailView, ListView
+from django.views.generic.edit import CreateView, DeleteView, UpdateView
+from django.views.generic.base import RedirectView
+from django.db import transaction
+from django.db.models import Q, Count
+from django.template.loader import get_template
+from django.template import Context
+from django.utils.decorators import method_decorator
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth import logout
+from django.contrib import messages
+from django.core.paginator import Paginator
+from gitrefinery.forms import EditRepositoryForm, EditReleaseForm, EditProfileForm
+import settings
+from . import utils
+
+
+
+class RepoListView(ListView):
+ model = Repository
+ context_object_name = 'repos'
+
+
+class RepoDetailView(DetailView):
+ model = Repository
+ context_object_name = 'repo'
+ slug_field = 'name'
+
+
+
+class ReleaseDetailView(DetailView):
+ model = Release
+ context_object_name = 'release'
+ slug_field = 'name'
+ paginate_by = 50
+
+ def get_context_data(self, **kwargs):
+ context = super(ReleaseDetailView, self).get_context_data(**kwargs)
+ release = context['release']
+
+ query_string = self.request.GET.get('q', '')
+ try:
+ query_category = int(self.request.GET.get('category', -1))
+ except ValueError:
+ query_category = -1
+ commits = release.commit_set.all().order_by('-id')
+
+ try:
+ query_authorgroup = int(self.request.GET.get('authorgroup', -1))
+ except ValueError:
+ query_authorgroup = -1
+
+ query_excludecategories_str = self.request.GET.get('excludecategories', '')
+ if query_excludecategories_str:
+ query_excludecategories = [int(i) for i in query_excludecategories_str.split(',')]
+ else:
+ query_excludecategories = []
+
+ if query_authorgroup == -2:
+ commits = commits.filter(author_obj__authorgroupmembership__isnull=True)
+ elif query_authorgroup > -1:
+ commits = commits.filter(author_obj__authorgroupmembership__group__id=query_authorgroup)
+
+ if query_string:
+ commits = commits.filter(Q(commit_message__contains=query_string) | Q(author__contains=query_string))
+
+ if query_category == -2:
+ commits = commits.filter(commitcategory__isnull=True)
+ elif query_category > -1:
+ commits = commits.filter(commitcategory__category_id=query_category).distinct()
+
+ if query_excludecategories:
+ commits = commits.exclude(commitcategory__category_id__in=query_excludecategories)
+
+ try:
+ all_tags = utils.git_get_tags(release.repository.path)
+ except:
+ all_tags = {}
+ # Reorg tags by commit
+ reverse_tags = {}
+ for tag, commit in all_tags.items():
+ reverse_tags[commit] = reverse_tags.get(commit, []) + [tag]
+
+ if self.paginate_by > 0:
+ paginator = Paginator(commits, self.paginate_by)
+ page = paginator.page(int(self.request.GET.get('page', '1')))
+ context['page_obj'] = page
+ context['commits'] = page.object_list
+ context['is_paginated'] = True
+ else:
+ context['commits'] = commits
+ context['is_paginated'] = False
+ context['authorgroups'] = AuthorGroup.objects.all()
+ context['search_keyword'] = query_string
+ context['search_category'] = query_category
+ context['search_authorgroup'] = query_authorgroup
+ context['excludecategories'] = query_excludecategories
+ if query_excludecategories:
+ all_category_names = dict(Category.objects.filter(repository=release.repository).values_list('id', 'name'))
+ category_names = [all_category_names[i] for i in query_excludecategories]
+ context['excludecategories_display'] = ','.join(category_names)
+ else:
+ context['excludecategories_display'] = ' (none)'
+ return context
+
+ def post(self, request, *args, **kwargs):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+
+ release = self.get_object()
+
+ def apply_commits():
+ for commit_hash in request.POST.getlist('selecteditems', ''):
+ if len(commit_hash) == 40:
+ obj = Commit.objects.get(release=release, revision=commit_hash)
+ yield obj
+
+ action = request.POST.get('action', '')
+ if action == 'categorise':
+ try:
+ category_id = int(request.POST.get('catvalue', -1))
+ except ValueError:
+ category_id = -1
+ if category_id > -1:
+ for obj in apply_commits():
+ obj.save()
+ category = get_object_or_404(Category, id=category_id, repository=release.repository)
+ commitcat, _ = CommitCategory.objects.get_or_create(commit=obj, category=category)
+ commitcat.save()
+ elif action == 'uncategorise':
+ try:
+ category_id = int(request.POST.get('catvalue', -1))
+ except ValueError:
+ category_id = -1
+ if category_id > -1:
+ for obj in apply_commits():
+ category = get_object_or_404(Category, id=category_id, repository=release.repository)
+ obj.commitcategory_set.filter(category=category).delete()
+
+ return self.get(request, *args, **kwargs)
+
+
+class ReleaseNotesView(DetailView):
+ model = Release
+ context_object_name = 'release'
+ slug_field = 'name'
+
+ def get_context_data(self, **kwargs):
+ context = super(ReleaseNotesView, self).get_context_data(**kwargs)
+ release = self.get_object()
+ titles = {}
+ notes = {}
+
+ for category in release.repository.category_set.all():
+ notes[category.id] = []
+ for commit in release.commit_set.all().order_by("id"):
+ for commitcat in commit.commitcategory_set.all():
+ if commitcat.note:
+ notes[commitcat.category.id].append("* %s" % commitcat.note)
+ else:
+ # Note not set, get it from the commit shortlog
+ notes[commitcat.category.id].append("* %s" % commit.shortlog)
+
+ output = []
+ for category in release.repository.category_set.all():
+ if category.title:
+ title = category.title
+ else:
+ continue
+ output.append('\n')
+ output.append(title)
+ output.append('-' * len(title))
+ if notes[category.id]:
+ output.extend(notes[category.id])
+ else:
+ output.append('None')
+
+ context['release_notes'] = '\n'.join(output)
+ return context
+
+
+class StatsView(TemplateView):
+ def get_context_data(self, **kwargs):
+ context = super(StatsView, self).get_context_data(**kwargs)
+
+ groups = AuthorGroup.objects.all()
+ context['groups'] = groups
+
+ ChartData = namedtuple('ChartData', ['chart', 'categorynames', 'releases'])
+ ChartReleaseData = namedtuple('ChartReleaseData', ['release', 'label', 'data', 'total'])
+
+ chartdatalist = []
+ charts = StatsChart.objects.all()
+ for chart in charts:
+ categorynames = [x.strip() for x in chart.categories.split(',')]
+
+ releasedatalist = []
+ for statschartrelease in chart.statschartrelease_set.all().order_by('order'):
+ release = statschartrelease.release
+
+ # Categories are per repo, not global
+ categories = Category.objects.filter(repository=release.repository).filter(name__in=categorynames)
+ data = []
+ #total = release.commit_set.filter(commitcategory__category__in=categories).filter(author_obj__authorgroupmembership__group__in=groups).distinct().count()
+ total = release.commit_set.count()
+
+ # We have to loop through categorynames here not categories so we get the right order
+ for category in categorynames:
+ for group in groups:
+ count = release.commit_set.filter(commitcategory__category__name=category).filter(author_obj__authorgroupmembership__group=group).distinct().count()
+ data.append(count / total * 100.0)
+ # Add "other"
+ count = release.commit_set.filter(commitcategory__category__name=category).filter(author_obj__authorgroupmembership__isnull=True).distinct().count()
+ data.append(count / total * 100.0)
+
+ if statschartrelease.label:
+ label = statschartrelease.label
+ else:
+ label = '%s: %s' % (release.repository.name, release.name)
+ releasedata = ChartReleaseData(release=release, label=label, data=data, total=total)
+ releasedatalist.append(releasedata)
+ chartdata = ChartData(chart=chart, categorynames=categorynames, releases=releasedatalist)
+ chartdatalist.append(chartdata)
+ context['chartdatalist'] = chartdatalist
+ return context
+
+
+def set_commit_note(request):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+
+ release_id = request.POST.get('release', '')
+ commit_hash = request.POST.get('commit', '')
+ category_id = request.POST.get('category', None)
+ note = request.POST.get('note', '')
+
+ if len(commit_hash) != 40:
+ return HttpResponseBadRequest()
+
+ release = get_object_or_404(Release, id=release_id)
+ category = get_object_or_404(Category, id=category_id, repository=release.repository)
+ commit = get_object_or_404(Commit, release=release, revision=commit_hash)
+ commitcat, _ = CommitCategory.objects.get_or_create(commit=commit, category=category)
+ commitcat.note = note
+ commitcat.save()
+
+ return HttpResponse('saved')
+
+
+def remove_commit_category(request):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+
+ release_id = request.POST.get('release', '')
+ commit_hash = request.POST.get('commit', '')
+ category_id = request.POST.get('category', None)
+
+ if len(commit_hash) != 40:
+ return HttpResponseBadRequest()
+
+ release = get_object_or_404(Release, id=release_id)
+ category = get_object_or_404(Category, id=category_id, repository=release.repository)
+ commit = get_object_or_404(Commit, release=release, revision=commit_hash)
+ commitcat = get_object_or_404(CommitCategory, commit=commit, category=category)
+ commitcat.delete()
+
+ return HttpResponse('success')
+
+
+def import_commits(request, template_name, pk):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+ release = get_object_or_404(Release, id=pk)
+
+ repo = git.Repo(release.repository.path)
+ assert repo.bare == False
+
+ lastobj = release.commit_set.order_by('-id')
+ if lastobj:
+ lastrev = lastobj[0].revision
+ all_commits = repo.iter_commits("%s..%s" % (lastrev, release.end_rev))
+ if not all_commits:
+ messages.info(request, 'No new commits to import since %s' % lastrev)
+ return HttpResponseRedirect(reverse('release', args=(release.id,)))
+ else:
+ all_commits = repo.iter_commits("%s..%s" % (release.begin_rev, release.end_rev))
+ if not all_commits:
+ messages.error(request, 'No commits in range between %s and %s' % (release.begin_rev, release.end_rev))
+ return HttpResponseRedirect(reverse('release', args=(release.id,)))
+
+ print('Import: gathering rules')
+ # FIXME not happy about using dicts here
+ rules = []
+ for rule in release.repository.categorisationrule_set.order_by('order'):
+ ruledict = {}
+ if rule.shortlog_regex:
+ ruledict['shortlog'] = re.compile(rule.shortlog_regex, re.IGNORECASE)
+ if rule.body_regex:
+ ruledict['message'] = re.compile(rule.body_regex, re.IGNORECASE)
+ if rule.path_regex:
+ ruledict['path'] = re.compile(rule.path_regex, re.IGNORECASE)
+ if rule.author_regex:
+ ruledict['author'] = re.compile(rule.author_regex, re.IGNORECASE)
+ if rule.value:
+ ruledict['value'] = rule.value
+ ruledict['category'] = rule.category
+ ruledict['stop_on_match'] = rule.stop_on_match
+ rules.append(ruledict)
+
+ print('Import: checking for reverted commits')
+
+ revert_re = re.compile('This reverts commit ([0-9a-z]+)\.')
+ reverts = []
+ reverted = []
+
+ category_revert = None
+ category_reverted = None
+ qry = Category.objects.filter(repository=release.repository, name='revert')
+ if qry:
+ category_revert = qry[0]
+ qry = Category.objects.filter(repository=release.repository, name='reverted')
+ if qry:
+ category_reverted = qry[0]
+
+ commitlist = []
+ for commit in all_commits:
+ rx = revert_re.search(commit.message)
+ if rx:
+ reverted.append(rx.group(1))
+ reverts.append(commit.hexsha)
+ commitlist.append(commit)
+
+ print('Import: processing commits')
+
+ counter = 0
+ maxcount = len(commitlist)
+ last_percent = -1
+ for commit in reversed(commitlist):
+ counter += 1
+ percent = int(counter / maxcount * 100)
+ if percent > last_percent:
+ print('imported %d%%' % percent)
+ last_percent = percent
+ commithash = commit.hexsha
+ commitobj = Commit()
+ commitobj.release = release
+ commitobj.revision = commithash
+ commitobj.author = commit.author.name
+ author, _ = Author.objects.get_or_create(name=commit.author.name,
+ email=commit.author.email)
+ commitobj.author_obj = author
+ commitobj.authored_date = datetime.fromtimestamp(commit.authored_date)
+ commitobj.committed_date = datetime.fromtimestamp(commit.committed_date)
+ commitobj.shortlog = commit.message.splitlines()[0]
+ commitobj.commit_message = commit.message
+ #commitobj.files =
+ commitobj.save()
+
+ if commithash in reverted and category_reverted:
+ # Reverted commits should just be ignored
+ commitcat = CommitCategory(commit=commitobj, category=category_reverted)
+ commitcat.save()
+ continue
+ elif commithash in reverts and category_revert:
+ # Reverts should just be ignored
+ commitcat = CommitCategory(commit=commitobj, category=category_revert)
+ commitcat.save()
+ continue
+
+ for ruledict in rules:
+ match_rx = None
+ rx = ruledict.get('shortlog', None)
+ if rx:
+ if rx.search(commitobj.shortlog):
+ match_rx = rx
+ else:
+ continue
+ rx = ruledict.get('message', None)
+ if rx:
+ if rx.search(commitobj.commit_message):
+ match_rx = rx
+ else:
+ continue
+ rx = ruledict.get('path', None)
+ if rx:
+ for pth in commit.stats.files:
+ if rx.search(pth):
+ match_rx = rx
+ break
+ else:
+ continue
+ rx = ruledict.get('author', None)
+ if rx:
+ if rx.search(commitobj.author):
+ match_rx = rx
+ else:
+ continue
+
+ if match_rx:
+ if ruledict['category']:
+ commitcat, _ = CommitCategory.objects.get_or_create(commit=commitobj, category=ruledict['category'])
+ value = ruledict.get('value', None)
+ if value:
+ commitcat.note = match_rx.sub(value, commitobj.commit_message)
+ else:
+ commitcat.note = commitobj.shortlog
+ commitcat.save()
+ if ruledict['stop_on_match']:
+ break
+
+ messages.info(request, 'categorisation rules applied')
+ return HttpResponseRedirect(reverse('release', args=(release.id,)))
+
+
+def group_authors(request):
+ groupmap = {}
+ for rule in AuthorGroupMatchRule.objects.all().order_by('order'):
+ if rule.email_regex:
+ groupmap[rule.group] = re.compile(rule.email_regex)
+ if not groupmap:
+ messages.warning(request, 'No AuthorGroupMatchRule records have email_regex set - nothing to do')
+ return HttpResponseRedirect(reverse('frontpage'))
+
+ changes = False
+ groupmsgs = []
+ for author in Author.objects.all():
+ for group, email_re in groupmap.items():
+ if email_re.match(author.email):
+ if author.authorgroupmembership_set.exists():
+ # Author is already in a group, skip
+ break
+ groupmsgs.append("Added %s to %s group" % (author, group.name))
+ groupm = AuthorGroupMembership()
+ groupm.author = author
+ groupm.group = group
+ groupm.save()
+ changes = True
+ break
+
+ if changes:
+ messages.success(request, 'Author group changes made:\n -' + ('\n -'.join(groupmsgs)))
+ else:
+ messages.info(request, 'No grouping changes needed to be made')
+
+ # Check if any authors are part of more than one group -
+ # if they are that's not necessarily a problem, you just need
+ # to be aware that they exist when interpreting the stats as the
+ # total percentages will then not add up to 100%
+ for author in Author.objects.all():
+ if author.authorgroupmembership_set.count() > 1:
+ messages.warning(request, '%s is a member of more than one group' % author)
+
+ return HttpResponseRedirect(reverse('frontpage'))
+
+
+def fetch_repository_view(request, reponame=None):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+ repo = get_object_or_404(Repository, name=reponame)
+ if not os.access(repo.path, os.W_OK):
+ raise Exception('Repo path %s is not writeable' % repo.path)
+ try:
+ frepo = git.Repo(repo.path)
+ for remote in frepo.remotes:
+ remote.fetch(prune=True)
+ messages.success(request, 'Fetched successfully')
+ except:
+ raise Exception("Failed to fetch repo")
+ return HttpResponseRedirect(reverse('repository', args=(repo.name,)))
+
+
+def edit_repository_view(request, template_name, reponame=None):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+ if reponame:
+ # Edit mode
+ repo = get_object_or_404(Repository, name=reponame)
+ else:
+ # Add mode
+ repo = Repository()
+
+ if request.method == 'POST':
+ form = EditRepositoryForm(request.POST, instance=repo)
+ if form.is_valid():
+ repobase = settings.REPO_BASE_DIR
+ if not repobase:
+ raise Exception('REPO_BASE_DIR not set')
+ insert = False
+ if not repo.pk:
+ insert = True
+ # FIXME this is ugly doing this synchronously
+ if not os.access(repobase, os.W_OK):
+ raise Exception('REPO_BASE_DIR %s is not writeable' % repobase)
+ repo.path = os.path.join(repobase, repo.name)
+ git.Repo.clone_from(form.cleaned_data['fetch_url'], repo.path)
+ form.save()
+
+ if insert:
+ copy_source = form.cleaned_data['copy_source']
+ if copy_source:
+ for category in copy_source.category_set.all():
+ category.pk = None
+ category.repository = repo
+ category.save()
+ for rule in copy_source.categorisationrule_set.all():
+ rule.pk = None
+ rule.repository = repo
+ rule.category = repo.category_set.filter(name=rule.category.name).first()
+ rule.save()
+
+ messages.success(request, 'Repository %s saved successfully.' % repo.name)
+ return HttpResponseRedirect(reverse('repository', args=(repo.name,)))
+ else:
+ form = EditRepositoryForm(instance=repo)
+
+ return render(request, template_name, {
+ 'form': form,
+ })
+
+
+def edit_release_view(request, template_name, reponame=None, pk=None):
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+ if pk:
+ # Edit mode
+ release = get_object_or_404(Release, pk=pk)
+ else:
+ # Add mode
+ repo = get_object_or_404(Repository, name=reponame)
+ release = Release()
+ release.repository = repo
+
+ if request.method == 'POST':
+ form = EditReleaseForm(request.POST, instance=release)
+ if form.is_valid():
+ form.save()
+ messages.success(request, 'Release %s saved successfully.' % release.name)
+ return HttpResponseRedirect(reverse('release', args=(release.id,)))
+ else:
+ form = EditReleaseForm(instance=release)
+
+ return render(request, template_name, {
+ 'form': form,
+ })
+
+
+class BaseDeleteView(DeleteView):
+
+ def get_context_data(self, **kwargs):
+ context = super(BaseDeleteView, self).get_context_data(**kwargs)
+ obj = context.get('object', None)
+ if obj:
+ context['object_type'] = obj._meta.verbose_name
+ cancel = self.request.GET.get('cancel', '')
+ if cancel:
+ if self.slug_field != 'slug':
+ args = (getattr(obj, self.slug_field),)
+ else:
+ args = (obj.pk,)
+ context['cancel_url'] = reverse_lazy(cancel, args=args)
+ return context
+
+
+class RepositoryDeleteView(BaseDeleteView):
+ model = Repository
+ slug_field = 'name'
+ success_url = reverse_lazy('repositories')
+
+ def delete(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ success_url = self.get_success_url()
+ repopath = self.object.path
+ self.object.delete()
+ # Tidy up after ourselves
+ basedir = settings.REPO_BASE_DIR
+ if basedir:
+ if basedir[-1] != '/':
+ basedir += '/'
+ if repopath.startswith(basedir) and os.path.exists(repopath):
+ shutil.rmtree(repopath)
+ return HttpResponseRedirect(success_url)
+
+class ReleaseDeleteView(BaseDeleteView):
+ model = Release
+
+ def get_success_url(self):
+ return reverse_lazy('repository', args=(self.get_object().repository.name,))
+
+
+class CategoryCheckListView(ListView):
+ model = Category
+ context_object_name = 'categories'
+
+ def get_queryset(self):
+ return Category.objects.filter(repository__id=self.kwargs['repository'])
+
+class EditProfileFormView(SuccessMessageMixin, UpdateView):
+ form_class = EditProfileForm
+
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ self.user = request.user
+ return super(EditProfileFormView, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super(EditProfileFormView, self).get_context_data(**kwargs)
+ form = context['form']
+ # Prepare a list of fields with errors
+ # We do this so that if there's a problem with the captcha, that's the only error shown
+ # (since we have a username field, we want to make user enumeration difficult)
+ if 'captcha' in form.errors:
+ error_fields = ['captcha']
+ else:
+ error_fields = form.errors.keys()
+ context['error_fields'] = error_fields
+ context['return_url'] = self.get_success_url()
+ return context
+
+ def get_object(self, queryset=None):
+ return self.user
+
+ def form_valid(self, form):
+ self.object = form.save()
+
+ if'answer_1' in form.changed_data:
+ # If one security answer has changed, they all have. Delete current questions and add new ones.
+ # Don't throw an error if we are editing the super user and they don't have security questions yet.
+ try:
+ self.user.userprofile.securityquestionanswer_set.all().delete()
+ user = self.user.userprofile
+ except UserProfile.DoesNotExist:
+ user = UserProfile.objects.create(user=self.user)
+
+ security_question_1 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_1"))
+ security_question_2 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_2"))
+ security_question_3 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_3"))
+ answer_1 = form.cleaned_data.get("answer_1").replace(" ", "").lower()
+ answer_2 = form.cleaned_data.get("answer_2").replace(" ", "").lower()
+ answer_3 = form.cleaned_data.get("answer_3").replace(" ", "").lower()
+
+ # Answers are hashed using Django's password hashing function make_password()
+ SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_1,
+ answer=make_password(answer_1))
+ SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_2,
+ answer=make_password(answer_2))
+ SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_3,
+ answer=make_password(answer_3))
+
+ if 'email' in form.changed_data:
+ # Take a copy of request.user as it is about to be invalidated by logout()
+ user = self.request.user
+ logout(self.request)
+ # Deactivate user and put through registration again
+ user.is_active = False
+ user.save()
+ view = RegistrationView()
+ view.request = self.request
+ view.send_activation_email(user)
+ return HttpResponseRedirect(reverse('reregister'))
+
+ return super(EditProfileFormView, self).form_valid(form)
+
+ def get_success_message(self, cleaned_data):
+ return "Profile saved successfully"
+
+ def get_success_url(self):
+ return self.request.GET.get('return_to', reverse('frontpage'))
+
+