1 # Copyright 2011-2012 GRNET S.A. All rights reserved.
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
7 # 1. Redistributions of source code must retain the above
8 # copyright notice, this list of conditions and the following
11 # 2. Redistributions in binary form must reproduce the above
12 # copyright notice, this list of conditions and the following
13 # disclaimer in the documentation and/or other materials
14 # provided with the distribution.
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
37 from urllib import quote
38 from functools import wraps
39 from datetime import datetime, timedelta
41 from django.contrib import messages
42 from django.contrib.auth.decorators import login_required
43 from django.contrib.auth.views import password_change
44 from django.core.urlresolvers import reverse
45 from django.db import transaction
46 from django.db.models import Q
47 from django.db.utils import IntegrityError
48 from django.forms.fields import URLField
49 from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, \
50 HttpResponseRedirect, HttpResponseBadRequest
51 from django.shortcuts import redirect
52 from django.template import RequestContext, loader
53 from django.utils.http import urlencode
54 from django.utils.translation import ugettext as _
55 from django.views.generic.create_update import (create_object, delete_object,
56 get_model_and_form_class
58 from django.views.generic.list_detail import object_list, object_detail
59 from django.http import HttpResponseBadRequest
61 from astakos.im.models import (
62 AstakosUser, ApprovalTerms, AstakosGroup, Resource,
63 EmailChange, GroupKind, Membership)
64 from astakos.im.activation_backends import get_backend, SimpleBackend
65 from astakos.im.util import get_context, prepare_response, set_cookie, get_query
66 from astakos.im.forms import (LoginForm, InvitationForm, ProfileForm,
67 FeedbackForm, SignApprovalTermsForm,
68 ExtendedPasswordChangeForm, EmailChangeForm,
69 AstakosGroupCreationForm, AstakosGroupSearchForm,
70 AstakosGroupUpdateForm)
71 from astakos.im.functions import (send_feedback, SendMailError,
72 invite as invite_func, logout as auth_logout,
73 activate as activate_func,
74 switch_account_to_shibboleth,
75 send_admin_notification,
76 SendNotificationError)
77 from astakos.im.settings import (
78 COOKIE_NAME, COOKIE_DOMAIN, SITENAME, LOGOUT_NEXT,
81 from astakos.im.tasks import request_billing
83 logger = logging.getLogger(__name__)
86 def render_response(template, tab=None, status=200, reset_cookie=False,
87 context_instance=None, **kwargs):
89 Calls ``django.template.loader.render_to_string`` with an additional ``tab``
90 keyword argument and returns an ``django.http.HttpResponse`` with the
94 tab = template.partition('_')[0].partition('.html')[0]
95 kwargs.setdefault('tab', tab)
96 html = loader.render_to_string(
97 template, kwargs, context_instance=context_instance)
98 response = HttpResponse(html, status=status)
100 set_cookie(response, context_instance['request'].user)
104 def requires_anonymous(func):
106 Decorator checkes whether the request.user is not Anonymous and in that case
107 redirects to `logout`.
110 def wrapper(request, *args):
111 if not request.user.is_anonymous():
112 next = urlencode({'next': request.build_absolute_uri()})
113 logout_uri = reverse(logout) + '?' + next
114 return HttpResponseRedirect(logout_uri)
115 return func(request, *args)
119 def signed_terms_required(func):
121 Decorator checkes whether the request.user is Anonymous and in that case
122 redirects to `logout`.
125 def wrapper(request, *args, **kwargs):
126 if request.user.is_authenticated() and not request.user.signed_terms:
127 params = urlencode({'next': request.build_absolute_uri(),
129 terms_uri = reverse('latest_terms') + '?' + params
130 return HttpResponseRedirect(terms_uri)
131 return func(request, *args, **kwargs)
135 @signed_terms_required
136 def index(request, login_template_name='im/login.html', extra_context=None):
138 If there is logged on user renders the profile page otherwise renders login page.
142 ``login_template_name``
143 A custom login template to use. This is optional; if not specified,
144 this will default to ``im/login.html``.
146 ``profile_template_name``
147 A custom profile template to use. This is optional; if not specified,
148 this will default to ``im/profile.html``.
151 An dictionary of variables to add to the template context.
155 im/profile.html or im/login.html or ``template_name`` keyword argument.
158 template_name = login_template_name
159 if request.user.is_authenticated():
160 return HttpResponseRedirect(reverse('edit_profile'))
161 return render_response(template_name,
162 login_form=LoginForm(request=request),
163 context_instance=get_context(request, extra_context))
167 @signed_terms_required
168 @transaction.commit_manually
169 def invite(request, template_name='im/invitations.html', extra_context=None):
171 Allows a user to invite somebody else.
173 In case of GET request renders a form for providing the invitee information.
174 In case of POST checks whether the user has not run out of invitations and then
175 sends an invitation email to singup to the service.
177 The view uses commit_manually decorator in order to ensure the number of the
178 user invitations is going to be updated only if the email has been successfully sent.
180 If the user isn't logged in, redirects to settings.LOGIN_URL.
185 A custom template to use. This is optional; if not specified,
186 this will default to ``im/invitations.html``.
189 An dictionary of variables to add to the template context.
193 im/invitations.html or ``template_name`` keyword argument.
197 The view expectes the following settings are defined:
199 * LOGIN_URL: login uri
200 * ASTAKOS_DEFAULT_CONTACT_EMAIL: service support email
204 form = InvitationForm()
206 inviter = request.user
207 if request.method == 'POST':
208 form = InvitationForm(request.POST)
209 if inviter.invitations > 0:
212 invitation = form.save()
213 invite_func(invitation, inviter)
214 message = _('Invitation sent to %s' % invitation.username)
215 messages.success(request, message)
216 except SendMailError, e:
218 messages.error(request, message)
219 transaction.rollback()
220 except BaseException, e:
221 message = _('Something went wrong.')
222 messages.error(request, message)
224 transaction.rollback()
228 message = _('No invitations left')
229 messages.error(request, message)
231 sent = [{'email': inv.username,
232 'realname': inv.realname,
233 'is_consumed': inv.is_consumed}
234 for inv in request.user.invitations_sent.all()]
235 kwargs = {'inviter': inviter,
237 context = get_context(request, extra_context, **kwargs)
238 return render_response(template_name,
239 invitation_form=form,
240 context_instance=context)
244 @signed_terms_required
245 def edit_profile(request, template_name='im/profile.html', extra_context=None):
247 Allows a user to edit his/her profile.
249 In case of GET request renders a form for displaying the user information.
250 In case of POST updates the user informantion and redirects to ``next``
251 url parameter if exists.
253 If the user isn't logged in, redirects to settings.LOGIN_URL.
258 A custom template to use. This is optional; if not specified,
259 this will default to ``im/profile.html``.
262 An dictionary of variables to add to the template context.
266 im/profile.html or ``template_name`` keyword argument.
270 The view expectes the following settings are defined:
272 * LOGIN_URL: login uri
274 extra_context = extra_context or {}
275 form = ProfileForm(instance=request.user)
276 extra_context['next'] = request.GET.get('next')
278 if request.method == 'POST':
279 form = ProfileForm(request.POST, instance=request.user)
282 prev_token = request.user.auth_token
284 reset_cookie = user.auth_token != prev_token
285 form = ProfileForm(instance=user)
286 next = request.POST.get('next')
288 return redirect(next)
289 msg = _('Profile has been updated successfully')
290 messages.success(request, msg)
291 except ValueError, ve:
292 messages.success(request, ve)
293 elif request.method == "GET":
294 if not request.user.is_verified:
295 request.user.is_verified = True
297 return render_response(template_name,
298 reset_cookie=reset_cookie,
300 context_instance=get_context(request,
304 def signup(request, template_name='im/signup.html', on_success='im/signup_complete.html', extra_context=None, backend=None):
306 Allows a user to create a local account.
308 In case of GET request renders a form for entering the user information.
309 In case of POST handles the signup.
311 The user activation will be delegated to the backend specified by the ``backend`` keyword argument
312 if present, otherwise to the ``astakos.im.activation_backends.InvitationBackend``
313 if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.activation_backends.SimpleBackend`` if not
314 (see activation_backends);
316 Upon successful user creation, if ``next`` url parameter is present the user is redirected there
317 otherwise renders the same page with a success message.
319 On unsuccessful creation, renders ``template_name`` with an error message.
324 A custom template to render. This is optional;
325 if not specified, this will default to ``im/signup.html``.
328 A custom template to render in case of success. This is optional;
329 if not specified, this will default to ``im/signup_complete.html``.
332 An dictionary of variables to add to the template context.
336 im/signup.html or ``template_name`` keyword argument.
337 im/signup_complete.html or ``on_success`` keyword argument.
339 if request.user.is_authenticated():
340 return HttpResponseRedirect(reverse('edit_profile'))
342 provider = get_query(request).get('provider', 'local')
345 backend = get_backend(request)
346 form = backend.get_signup_form(provider)
348 form = SimpleBackend(request).get_signup_form(provider)
349 messages.error(request, e)
350 if request.method == 'POST':
352 user = form.save(commit=False)
354 result = backend.handle_activation(user)
355 status = messages.SUCCESS
356 message = result.message
358 if 'additional_email' in form.cleaned_data:
359 additional_email = form.cleaned_data['additional_email']
360 if additional_email != user.email:
361 user.additionalmail_set.create(email=additional_email)
362 msg = 'Additional email: %s saved for user %s.' % (
363 additional_email, user.email)
364 logger.log(LOGGING_LEVEL, msg)
365 if user and user.is_active:
366 next = request.POST.get('next', '')
367 return prepare_response(request, user, next=next)
368 messages.add_message(request, status, message)
369 return render_response(on_success,
370 context_instance=get_context(request, extra_context))
371 except SendMailError, e:
373 messages.error(request, message)
374 except BaseException, e:
375 message = _('Something went wrong.')
376 messages.error(request, message)
378 return render_response(template_name,
381 context_instance=get_context(request, extra_context))
385 @signed_terms_required
386 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context=None):
388 Allows a user to send feedback.
390 In case of GET request renders a form for providing the feedback information.
391 In case of POST sends an email to support team.
393 If the user isn't logged in, redirects to settings.LOGIN_URL.
398 A custom template to use. This is optional; if not specified,
399 this will default to ``im/feedback.html``.
402 An dictionary of variables to add to the template context.
406 im/signup.html or ``template_name`` keyword argument.
410 * LOGIN_URL: login uri
411 * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
413 if request.method == 'GET':
414 form = FeedbackForm()
415 if request.method == 'POST':
417 return HttpResponse('Unauthorized', status=401)
419 form = FeedbackForm(request.POST)
421 msg = form.cleaned_data['feedback_msg']
422 data = form.cleaned_data['feedback_data']
424 send_feedback(msg, data, request.user, email_template_name)
425 except SendMailError, e:
426 messages.error(request, message)
428 message = _('Feedback successfully sent')
429 messages.success(request, message)
430 return render_response(template_name,
432 context_instance=get_context(request, extra_context))
435 @signed_terms_required
436 def logout(request, template='registration/logged_out.html', extra_context=None):
438 Wraps `django.contrib.auth.logout` and delete the cookie.
440 response = HttpResponse()
441 if request.user.is_authenticated():
442 email = request.user.email
444 response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
445 msg = 'Cookie deleted for %s' % email
446 logger.log(LOGGING_LEVEL, msg)
447 next = request.GET.get('next')
449 response['Location'] = next
450 response.status_code = 302
453 response['Location'] = LOGOUT_NEXT
454 response.status_code = 301
456 messages.success(request, _('You have successfully logged out.'))
457 context = get_context(request, extra_context)
458 response.write(loader.render_to_string(template, context_instance=context))
462 @transaction.commit_manually
463 def activate(request, greeting_email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
465 Activates the user identified by the ``auth`` request parameter, sends a welcome email
466 and renews the user token.
468 The view uses commit_manually decorator in order to ensure the user state will be updated
469 only if the email will be send successfully.
471 token = request.GET.get('auth')
472 next = request.GET.get('next')
474 user = AstakosUser.objects.get(auth_token=token)
475 except AstakosUser.DoesNotExist:
476 return HttpResponseBadRequest(_('No such user'))
479 message = _('Account already active.')
480 messages.error(request, message)
481 return index(request)
484 local_user = AstakosUser.objects.get(
489 except AstakosUser.DoesNotExist:
493 greeting_email_template_name,
494 helpdesk_email_template_name,
497 response = prepare_response(request, user, next, renew=True)
500 except SendMailError, e:
502 messages.error(request, message)
503 transaction.rollback()
504 return index(request)
505 except BaseException, e:
506 message = _('Something went wrong.')
507 messages.error(request, message)
509 transaction.rollback()
510 return index(request)
513 user = switch_account_to_shibboleth(
516 greeting_email_template_name
518 response = prepare_response(request, user, next, renew=True)
521 except SendMailError, e:
523 messages.error(request, message)
524 transaction.rollback()
525 return index(request)
526 except BaseException, e:
527 message = _('Something went wrong.')
528 messages.error(request, message)
530 transaction.rollback()
531 return index(request)
534 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context=None):
539 term = ApprovalTerms.objects.order_by('-id')[0]
544 term = ApprovalTerms.objects.get(id=term_id)
545 except ApprovalTerms.DoesNotExist, e:
549 return HttpResponseRedirect(reverse('index'))
550 f = open(term.location, 'r')
553 if request.method == 'POST':
554 next = request.POST.get('next')
556 next = reverse('index')
557 form = SignApprovalTermsForm(request.POST, instance=request.user)
558 if not form.is_valid():
559 return render_response(template_name,
561 approval_terms_form=form,
562 context_instance=get_context(request, extra_context))
564 return HttpResponseRedirect(next)
567 if request.user.is_authenticated() and not request.user.signed_terms:
568 form = SignApprovalTermsForm(instance=request.user)
569 return render_response(template_name,
571 approval_terms_form=form,
572 context_instance=get_context(request, extra_context))
575 @signed_terms_required
576 def change_password(request):
577 return password_change(request,
578 post_change_redirect=reverse('edit_profile'),
579 password_change_form=ExtendedPasswordChangeForm)
582 @signed_terms_required
584 @transaction.commit_manually
585 def change_email(request, activation_key=None,
586 email_template_name='registration/email_change_email.txt',
587 form_template_name='registration/email_change_form.html',
588 confirm_template_name='registration/email_change_done.html',
592 user = EmailChange.objects.change_email(activation_key)
593 if request.user.is_authenticated() and request.user == user:
594 msg = _('Email changed successfully.')
595 messages.success(request, msg)
597 response = prepare_response(request, user)
600 except ValueError, e:
601 messages.error(request, e)
602 return render_response(confirm_template_name,
603 modified_user=user if 'user' in locals(
605 context_instance=get_context(request,
608 if not request.user.is_authenticated():
609 path = quote(request.get_full_path())
610 url = request.build_absolute_uri(reverse('index'))
611 return HttpResponseRedirect(url + '?next=' + path)
612 form = EmailChangeForm(request.POST or None)
613 if request.method == 'POST' and form.is_valid():
615 ec = form.save(email_template_name, request)
616 except SendMailError, e:
618 messages.error(request, msg)
619 transaction.rollback()
620 except IntegrityError, e:
621 msg = _('There is already a pending change email request.')
622 messages.error(request, msg)
624 msg = _('Change email request has been registered succefully.\
625 You are going to receive a verification email in the new address.')
626 messages.success(request, msg)
628 return render_response(form_template_name,
630 context_instance=get_context(request,
634 @signed_terms_required
636 def group_add(request, kind_name='default'):
638 kind = GroupKind.objects.get(name=kind_name)
640 return HttpResponseBadRequest(_('No such group kind'))
642 template_loader = loader
643 post_save_redirect = '/im/group/%(id)s/'
644 context_processors = None
645 model, form_class = get_model_and_form_class(
647 form_class=AstakosGroupCreationForm
650 (str(r.id), r) for r in Resource.objects.select_related().all())
652 if request.method == 'POST':
653 form = form_class(request.POST, request.FILES, resources=resources)
655 new_object = form.save()
658 new_object.owners = [request.user]
660 # save quota policies
661 for (rid, limit) in form.resources():
666 # TODO Should I stay or should I go???
669 new_object.astakosgroupquota_set.create(
673 policies.append('%s %d' % (r, limit))
674 msg = _("The %(verbose_name)s was created successfully.") %\
675 {"verbose_name": model._meta.verbose_name}
676 messages.success(request, msg, fail_silently=True)
680 send_admin_notification(
681 template_name='im/group_creation_notification.txt',
684 'owner': request.user,
685 'policies': policies,
687 subject='%s alpha2 testing group creation notification' % SITENAME
689 except SendNotificationError, e:
690 messages.error(request, e, fail_silently=True)
691 return HttpResponseRedirect(post_save_redirect % new_object.__dict__)
697 form = form_class(data, resources=resources)
699 # Create the template, context, response
700 template_name = "%s/%s_form.html" % (
701 model._meta.app_label,
702 model._meta.object_name.lower()
704 t = template_loader.get_template(template_name)
705 c = RequestContext(request, {
708 }, context_processors)
709 return HttpResponse(t.render(c))
712 @signed_terms_required
714 def group_list(request):
715 list = request.user.astakos_groups.select_related().all()
716 return object_list(request, queryset=list,
723 @signed_terms_required
725 def group_detail(request, group_id):
727 group = AstakosGroup.objects.select_related().get(id=group_id)
728 except AstakosGroup.DoesNotExist:
729 return HttpResponseBadRequest(_('Invalid group.'))
730 form = AstakosGroupUpdateForm(instance=group)
731 return object_detail(request,
732 AstakosGroup.objects.all(),
734 extra_context={'quota': group.quota,
739 def group_update(request, group_id):
740 if request.method != 'POST':
741 return HttpResponseBadRequest('Method not allowed.')
743 group = AstakosGroup.objects.select_related().get(id=group_id)
744 except AstakosGroup.DoesNotExist:
745 return HttpResponseBadRequest(_('Invalid group.'))
746 form = AstakosGroupUpdateForm(request.POST, instance=group)
749 return group_detail(request, group_id)
751 @signed_terms_required
753 def group_search(request, extra_context=None, **kwargs):
754 if request.method == 'GET':
755 form = AstakosGroupSearchForm()
757 form = AstakosGroupSearchForm(get_query(request))
759 q = form.cleaned_data['q'].strip()
760 queryset = AstakosGroup.objects.select_related(
761 ).filter(name__contains=q)
765 template_name='im/astakosgroup_list.html',
771 return render_response(
772 template='im/astakosgroup_list.html',
774 context_instance=get_context(request, extra_context),
779 @signed_terms_required
781 def group_join(request, group_id):
782 m = Membership(group_id=group_id,
784 date_requested=datetime.now()
788 post_save_redirect = reverse(
790 kwargs=dict(group_id=group_id)
792 return HttpResponseRedirect(post_save_redirect)
793 except IntegrityError, e:
795 msg = _('Failed to join group.')
796 messages.error(request, msg)
797 return group_search(request)
800 @signed_terms_required
802 def group_leave(request, group_id):
804 m = Membership.objects.select_related().get(
808 except Membership.DoesNotExist:
809 return HttpResponseBadRequest(_('Invalid membership.'))
810 if request.user in m.group.owner.all():
811 return HttpResponseForbidden(_('Owner can not leave the group.'))
812 return delete_object(
816 template_name='im/astakosgroup_list.html',
817 post_delete_redirect=reverse(
819 kwargs=dict(group_id=group_id)
824 def handle_membership():
827 def wrapper(request, group_id, user_id):
829 m = Membership.objects.select_related().get(
833 except Membership.DoesNotExist:
834 return HttpResponseBadRequest(_('Invalid membership.'))
836 if request.user not in m.group.owner.all():
837 return HttpResponseForbidden(_('User is not a group owner.'))
839 return render_response(
840 template='im/astakosgroup_detail.html',
841 context_instance=get_context(request),
849 @signed_terms_required
852 def approve_member(request, membership):
855 realname = membership.person.realname
856 msg = _('%s has been successfully joined the group.' % realname)
857 messages.success(request, msg)
858 except BaseException, e:
860 msg = _('Something went wrong during %s\'s approval.' % realname)
861 messages.error(request, msg)
864 @signed_terms_required
867 def disapprove_member(request, membership):
869 membership.disapprove()
870 realname = membership.person.realname
871 msg = _('%s has been successfully removed from the group.' % realname)
872 messages.success(request, msg)
873 except BaseException, e:
875 msg = _('Something went wrong during %s\'s disapproval.' % realname)
876 messages.error(request, msg)
879 @signed_terms_required
881 def resource_list(request):
882 return render_response(
883 template='im/astakosuserquota_list.html',
884 context_instance=get_context(request),
885 quota=request.user.quota
889 def group_create_list(request):
890 return render_response(
891 template='im/astakosgroup_create_list.html',
892 context_instance=get_context(request),
896 @signed_terms_required
898 def billing(request):
899 today = datetime.today()
900 month_last_day = calendar.monthrange(today.year, today.month)[1]
901 start = datetime(today.year, today.month, 1).strftime("%s")
902 end = datetime(today.year, today.month, month_last_day).strftime("%s")
903 r = request_billing.apply(args=(request.user.email,
909 status, data = r.result
911 messages.error(request, _('Service response status: %d' % status))
913 messages.error(request, r.result)
914 return render_response(
915 template='im/billing.html',
916 context_instance=get_context(request),