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.
36 from urllib import quote
37 from functools import wraps
38 from datetime import datetime, timedelta
40 from django.contrib import messages
41 from django.contrib.auth.decorators import login_required
42 from django.contrib.auth.views import password_change
43 from django.core.urlresolvers import reverse
44 from django.db import transaction
45 from django.db.models import Q
46 from django.db.utils import IntegrityError
47 from django.forms.fields import URLField
48 from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, \
49 HttpResponseRedirect, HttpResponseBadRequest
50 from django.shortcuts import redirect
51 from django.template import RequestContext, loader
52 from django.utils.http import urlencode
53 from django.utils.translation import ugettext as _
54 from django.views.generic.create_update import (create_object, delete_object,
55 get_model_and_form_class
57 from django.views.generic.list_detail import object_list, object_detail
59 from astakos.im.models import (AstakosUser, ApprovalTerms, AstakosGroup, Resource,
60 EmailChange, GroupKind, Membership)
61 from astakos.im.activation_backends import get_backend, SimpleBackend
62 from astakos.im.util import get_context, prepare_response, set_cookie, get_query
63 from astakos.im.forms import (LoginForm, InvitationForm, ProfileForm, FeedbackForm,
64 SignApprovalTermsForm, ExtendedPasswordChangeForm, EmailChangeForm,
65 AstakosGroupCreationForm, AstakosGroupSearchForm
67 from astakos.im.functions import (send_feedback, SendMailError,
68 invite as invite_func, logout as auth_logout, activate as activate_func,
69 switch_account_to_shibboleth, send_admin_notification, SendNotificationError
71 from astakos.im.settings import (COOKIE_NAME, COOKIE_DOMAIN, SITENAME, LOGOUT_NEXT,
75 logger = logging.getLogger(__name__)
77 def render_response(template, tab=None, status=200, reset_cookie=False, context_instance=None, **kwargs):
79 Calls ``django.template.loader.render_to_string`` with an additional ``tab``
80 keyword argument and returns an ``django.http.HttpResponse`` with the
84 tab = template.partition('_')[0].partition('.html')[0]
85 kwargs.setdefault('tab', tab)
86 html = loader.render_to_string(template, kwargs, context_instance=context_instance)
87 response = HttpResponse(html, status=status)
89 set_cookie(response, context_instance['request'].user)
93 def requires_anonymous(func):
95 Decorator checkes whether the request.user is not Anonymous and in that case
96 redirects to `logout`.
99 def wrapper(request, *args):
100 if not request.user.is_anonymous():
101 next = urlencode({'next': request.build_absolute_uri()})
102 logout_uri = reverse(logout) + '?' + next
103 return HttpResponseRedirect(logout_uri)
104 return func(request, *args)
107 def signed_terms_required(func):
109 Decorator checkes whether the request.user is Anonymous and in that case
110 redirects to `logout`.
113 def wrapper(request, *args, **kwargs):
114 if request.user.is_authenticated() and not request.user.signed_terms():
115 params = urlencode({'next': request.build_absolute_uri(),
117 terms_uri = reverse('latest_terms') + '?' + params
118 return HttpResponseRedirect(terms_uri)
119 return func(request, *args, **kwargs)
122 @signed_terms_required
123 def index(request, login_template_name='im/login.html', extra_context=None):
125 If there is logged on user renders the profile page otherwise renders login page.
129 ``login_template_name``
130 A custom login template to use. This is optional; if not specified,
131 this will default to ``im/login.html``.
133 ``profile_template_name``
134 A custom profile template to use. This is optional; if not specified,
135 this will default to ``im/profile.html``.
138 An dictionary of variables to add to the template context.
142 im/profile.html or im/login.html or ``template_name`` keyword argument.
145 template_name = login_template_name
146 if request.user.is_authenticated():
147 return HttpResponseRedirect(reverse('edit_profile'))
148 return render_response(template_name,
149 login_form = LoginForm(request=request),
150 context_instance = get_context(request, extra_context))
153 @signed_terms_required
154 @transaction.commit_manually
155 def invite(request, template_name='im/invitations.html', extra_context=None):
157 Allows a user to invite somebody else.
159 In case of GET request renders a form for providing the invitee information.
160 In case of POST checks whether the user has not run out of invitations and then
161 sends an invitation email to singup to the service.
163 The view uses commit_manually decorator in order to ensure the number of the
164 user invitations is going to be updated only if the email has been successfully sent.
166 If the user isn't logged in, redirects to settings.LOGIN_URL.
171 A custom template to use. This is optional; if not specified,
172 this will default to ``im/invitations.html``.
175 An dictionary of variables to add to the template context.
179 im/invitations.html or ``template_name`` keyword argument.
183 The view expectes the following settings are defined:
185 * LOGIN_URL: login uri
186 * ASTAKOS_DEFAULT_CONTACT_EMAIL: service support email
187 * ASTAKOS_DEFAULT_FROM_EMAIL: from email
191 form = InvitationForm()
193 inviter = request.user
194 if request.method == 'POST':
195 form = InvitationForm(request.POST)
196 if inviter.invitations > 0:
199 invitation = form.save()
200 invite_func(invitation, inviter)
201 message = _('Invitation sent to %s' % invitation.username)
202 messages.success(request, message)
203 except SendMailError, e:
205 messages.error(request, message)
206 transaction.rollback()
207 except BaseException, e:
208 message = _('Something went wrong.')
209 messages.error(request, message)
211 transaction.rollback()
215 message = _('No invitations left')
216 messages.error(request, message)
218 sent = [{'email': inv.username,
219 'realname': inv.realname,
220 'is_consumed': inv.is_consumed}
221 for inv in request.user.invitations_sent.all()]
222 kwargs = {'inviter': inviter,
224 context = get_context(request, extra_context, **kwargs)
225 return render_response(template_name,
226 invitation_form = form,
227 context_instance = context)
230 @signed_terms_required
231 def edit_profile(request, template_name='im/profile.html', extra_context=None):
233 Allows a user to edit his/her profile.
235 In case of GET request renders a form for displaying the user information.
236 In case of POST updates the user informantion and redirects to ``next``
237 url parameter if exists.
239 If the user isn't logged in, redirects to settings.LOGIN_URL.
244 A custom template to use. This is optional; if not specified,
245 this will default to ``im/profile.html``.
248 An dictionary of variables to add to the template context.
252 im/profile.html or ``template_name`` keyword argument.
256 The view expectes the following settings are defined:
258 * LOGIN_URL: login uri
260 extra_context = extra_context or {}
261 form = ProfileForm(instance=request.user)
262 extra_context['next'] = request.GET.get('next')
264 if request.method == 'POST':
265 form = ProfileForm(request.POST, instance=request.user)
268 prev_token = request.user.auth_token
270 reset_cookie = user.auth_token != prev_token
271 form = ProfileForm(instance=user)
272 next = request.POST.get('next')
274 return redirect(next)
275 msg = _('Profile has been updated successfully')
276 messages.success(request, msg)
277 except ValueError, ve:
278 messages.success(request, ve)
279 elif request.method == "GET":
280 if not request.user.is_verified:
281 request.user.is_verified = True
283 return render_response(template_name,
284 reset_cookie = reset_cookie,
286 context_instance = get_context(request,
289 def signup(request, template_name='im/signup.html', on_success='im/signup_complete.html', extra_context=None, backend=None):
291 Allows a user to create a local account.
293 In case of GET request renders a form for entering the user information.
294 In case of POST handles the signup.
296 The user activation will be delegated to the backend specified by the ``backend`` keyword argument
297 if present, otherwise to the ``astakos.im.activation_backends.InvitationBackend``
298 if settings.ASTAKOS_INVITATIONS_ENABLED is True or ``astakos.im.activation_backends.SimpleBackend`` if not
299 (see activation_backends);
301 Upon successful user creation, if ``next`` url parameter is present the user is redirected there
302 otherwise renders the same page with a success message.
304 On unsuccessful creation, renders ``template_name`` with an error message.
309 A custom template to render. This is optional;
310 if not specified, this will default to ``im/signup.html``.
313 A custom template to render in case of success. This is optional;
314 if not specified, this will default to ``im/signup_complete.html``.
317 An dictionary of variables to add to the template context.
321 im/signup.html or ``template_name`` keyword argument.
322 im/signup_complete.html or ``on_success`` keyword argument.
324 if request.user.is_authenticated():
325 return HttpResponseRedirect(reverse('edit_profile'))
327 provider = get_query(request).get('provider', 'local')
330 backend = get_backend(request)
331 form = backend.get_signup_form(provider)
333 form = SimpleBackend(request).get_signup_form(provider)
334 messages.error(request, e)
335 if request.method == 'POST':
337 user = form.save(commit=False)
339 result = backend.handle_activation(user)
340 status = messages.SUCCESS
341 message = result.message
343 if 'additional_email' in form.cleaned_data:
344 additional_email = form.cleaned_data['additional_email']
345 if additional_email != user.email:
346 user.additionalmail_set.create(email=additional_email)
347 msg = 'Additional email: %s saved for user %s.' % (additional_email, user.email)
348 logger.log(LOGGING_LEVEL, msg)
349 if user and user.is_active:
350 next = request.POST.get('next', '')
351 return prepare_response(request, user, next=next)
352 messages.add_message(request, status, message)
353 return render_response(on_success,
354 context_instance=get_context(request, extra_context))
355 except SendMailError, e:
357 messages.error(request, message)
358 except BaseException, e:
359 message = _('Something went wrong.')
360 messages.error(request, message)
362 return render_response(template_name,
365 context_instance=get_context(request, extra_context))
368 @signed_terms_required
369 def feedback(request, template_name='im/feedback.html', email_template_name='im/feedback_mail.txt', extra_context=None):
371 Allows a user to send feedback.
373 In case of GET request renders a form for providing the feedback information.
374 In case of POST sends an email to support team.
376 If the user isn't logged in, redirects to settings.LOGIN_URL.
381 A custom template to use. This is optional; if not specified,
382 this will default to ``im/feedback.html``.
385 An dictionary of variables to add to the template context.
389 im/signup.html or ``template_name`` keyword argument.
393 * LOGIN_URL: login uri
394 * ASTAKOS_DEFAULT_CONTACT_EMAIL: List of feedback recipients
396 if request.method == 'GET':
397 form = FeedbackForm()
398 if request.method == 'POST':
400 return HttpResponse('Unauthorized', status=401)
402 form = FeedbackForm(request.POST)
404 msg = form.cleaned_data['feedback_msg']
405 data = form.cleaned_data['feedback_data']
407 send_feedback(msg, data, request.user, email_template_name)
408 except SendMailError, e:
409 messages.error(request, message)
411 message = _('Feedback successfully sent')
412 messages.success(request, message)
413 return render_response(template_name,
414 feedback_form = form,
415 context_instance = get_context(request, extra_context))
417 @signed_terms_required
418 def logout(request, template='registration/logged_out.html', extra_context=None):
420 Wraps `django.contrib.auth.logout` and delete the cookie.
422 response = HttpResponse()
423 if request.user.is_authenticated():
424 email = request.user.email
426 response.delete_cookie(COOKIE_NAME, path='/', domain=COOKIE_DOMAIN)
427 msg = 'Cookie deleted for %s' % email
428 logger.log(LOGGING_LEVEL, msg)
429 next = request.GET.get('next')
431 response['Location'] = next
432 response.status_code = 302
435 response['Location'] = LOGOUT_NEXT
436 response.status_code = 301
438 messages.success(request, _('You have successfully logged out.'))
439 context = get_context(request, extra_context)
440 response.write(loader.render_to_string(template, context_instance=context))
443 @transaction.commit_manually
444 def activate(request, greeting_email_template_name='im/welcome_email.txt', helpdesk_email_template_name='im/helpdesk_notification.txt'):
446 Activates the user identified by the ``auth`` request parameter, sends a welcome email
447 and renews the user token.
449 The view uses commit_manually decorator in order to ensure the user state will be updated
450 only if the email will be send successfully.
452 token = request.GET.get('auth')
453 next = request.GET.get('next')
455 user = AstakosUser.objects.get(auth_token=token)
456 except AstakosUser.DoesNotExist:
457 return HttpResponseBadRequest(_('No such user'))
460 message = _('Account already active.')
461 messages.error(request, message)
462 return index(request)
465 local_user = AstakosUser.objects.get(
470 except AstakosUser.DoesNotExist:
474 greeting_email_template_name,
475 helpdesk_email_template_name,
478 response = prepare_response(request, user, next, renew=True)
481 except SendMailError, e:
483 messages.error(request, message)
484 transaction.rollback()
485 return index(request)
486 except BaseException, e:
487 message = _('Something went wrong.')
488 messages.error(request, message)
490 transaction.rollback()
491 return index(request)
494 user = switch_account_to_shibboleth(
497 greeting_email_template_name
499 response = prepare_response(request, user, next, renew=True)
502 except SendMailError, e:
504 messages.error(request, message)
505 transaction.rollback()
506 return index(request)
507 except BaseException, e:
508 message = _('Something went wrong.')
509 messages.error(request, message)
511 transaction.rollback()
512 return index(request)
514 def approval_terms(request, term_id=None, template_name='im/approval_terms.html', extra_context=None):
519 term = ApprovalTerms.objects.order_by('-id')[0]
524 term = ApprovalTerms.objects.get(id=term_id)
525 except ApprovalTerms.DoesNotExist, e:
529 return HttpResponseRedirect(reverse('index'))
530 f = open(term.location, 'r')
533 if request.method == 'POST':
534 next = request.POST.get('next')
536 next = reverse('index')
537 form = SignApprovalTermsForm(request.POST, instance=request.user)
538 if not form.is_valid():
539 return render_response(template_name,
541 approval_terms_form = form,
542 context_instance = get_context(request, extra_context))
544 return HttpResponseRedirect(next)
547 if request.user.is_authenticated() and not request.user.signed_terms():
548 form = SignApprovalTermsForm(instance=request.user)
549 return render_response(template_name,
551 approval_terms_form = form,
552 context_instance = get_context(request, extra_context))
554 @signed_terms_required
555 def change_password(request):
556 return password_change(request,
557 post_change_redirect=reverse('edit_profile'),
558 password_change_form=ExtendedPasswordChangeForm)
560 @signed_terms_required
562 @transaction.commit_manually
563 def change_email(request, activation_key=None,
564 email_template_name='registration/email_change_email.txt',
565 form_template_name='registration/email_change_form.html',
566 confirm_template_name='registration/email_change_done.html',
570 user = EmailChange.objects.change_email(activation_key)
571 if request.user.is_authenticated() and request.user == user:
572 msg = _('Email changed successfully.')
573 messages.success(request, msg)
575 response = prepare_response(request, user)
578 except ValueError, e:
579 messages.error(request, e)
580 return render_response(confirm_template_name,
581 modified_user = user if 'user' in locals() else None,
582 context_instance = get_context(request,
585 if not request.user.is_authenticated():
586 path = quote(request.get_full_path())
587 url = request.build_absolute_uri(reverse('index'))
588 return HttpResponseRedirect(url + '?next=' + path)
589 form = EmailChangeForm(request.POST or None)
590 if request.method == 'POST' and form.is_valid():
592 ec = form.save(email_template_name, request)
593 except SendMailError, e:
595 messages.error(request, msg)
596 transaction.rollback()
597 except IntegrityError, e:
598 msg = _('There is already a pending change email request.')
599 messages.error(request, msg)
601 msg = _('Change email request has been registered succefully.\
602 You are going to receive a verification email in the new address.')
603 messages.success(request, msg)
605 return render_response(form_template_name,
607 context_instance = get_context(request,
610 @signed_terms_required
612 def group_add(request, kind_name='default'):
614 kind = GroupKind.objects.get(name = kind_name)
616 return HttpResponseBadRequest(_('No such group kind'))
618 template_loader=loader
619 post_save_redirect='/im/group/%(id)s/'
620 context_processors=None
621 model, form_class = get_model_and_form_class(
623 form_class=AstakosGroupCreationForm
625 resources = dict( (str(r.id), r) for r in Resource.objects.select_related().all() )
627 if request.method == 'POST':
628 form = form_class(request.POST, request.FILES, resources=resources)
630 new_object = form.save()
633 new_object.owners = [request.user]
635 # save quota policies
636 for (rid, limit) in form.resources():
641 # TODO Should I stay or should I go???
644 new_object.astakosgroupquota_set.create(
648 policies.append('%s %d' % (r, limit))
649 msg = _("The %(verbose_name)s was created successfully.") %\
650 {"verbose_name": model._meta.verbose_name}
651 messages.success(request, msg, fail_silently=True)
655 send_admin_notification(
656 template_name='im/group_creation_notification.txt',
659 'owner':request.user,
662 subject='%s alpha2 testing group creation notification' % SITENAME
664 except SendNotificationError, e:
665 messages.error(request, e, fail_silently=True)
666 return HttpResponseRedirect(post_save_redirect % new_object.__dict__)
672 'expiration_date':now + timedelta(days=30)
674 form = form_class(data, resources=resources)
676 # Create the template, context, response
677 template_name = "%s/%s_form.html" % (
678 model._meta.app_label,
679 model._meta.object_name.lower()
681 t = template_loader.get_template(template_name)
682 c = RequestContext(request, {
684 }, context_processors)
685 return HttpResponse(t.render(c))
687 @signed_terms_required
689 def group_list(request):
690 list = request.user.astakos_groups.select_related().all()
691 return object_list(request, queryset=list)
693 @signed_terms_required
695 def group_detail(request, group_id):
697 group = AstakosGroup.objects.select_related().get(id=group_id)
698 except AstakosGroup.DoesNotExist:
699 return HttpResponseBadRequest(_('Invalid group.'))
700 return object_detail(request,
701 AstakosGroup.objects.all(),
703 extra_context = {'quota':group.quota}
706 @signed_terms_required
708 def group_approval_request(request, group_id):
709 return HttpResponse()
711 @signed_terms_required
713 def group_search(request, extra_context=None, **kwargs):
714 if request.method == 'GET':
715 form = AstakosGroupSearchForm()
717 form = AstakosGroupSearchForm(get_query(request))
719 q = form.cleaned_data['q'].strip()
720 q = URLField().to_python(q)
721 queryset = AstakosGroup.objects.select_related().filter(name=q)
725 template_name='im/astakosgroup_list.html',
731 return render_response(
732 template='im/astakosgroup_list.html',
734 context_instance=get_context(request, extra_context)
737 @signed_terms_required
739 def group_join(request, group_id):
740 m = Membership(group_id=group_id,
742 date_requested=datetime.now()
746 post_save_redirect = reverse(
748 kwargs=dict(group_id=group_id)
750 return HttpResponseRedirect(post_save_redirect)
751 except IntegrityError, e:
753 msg = _('Failed to join group.')
754 messages.error(request, msg)
755 return group_search(request)
757 @signed_terms_required
759 def group_leave(request, group_id):
761 m = Membership.objects.select_related().get(
765 except Membership.DoesNotExist:
766 return HttpResponseBadRequest(_('Invalid membership.'))
767 if request.user in m.group.owner.all():
768 return HttpResponseForbidden(_('Owner can not leave the group.'))
769 return delete_object(
773 template_name='im/astakosgroup_list.html',
774 post_delete_redirect = reverse(
776 kwargs=dict(group_id=group_id)
780 def handle_membership():
783 def wrapper(request, group_id, user_id):
785 m = Membership.objects.select_related().get(
789 except Membership.DoesNotExist:
790 return HttpResponseBadRequest(_('Invalid membership.'))
792 if request.user not in m.group.owner.all():
793 return HttpResponseForbidden(_('User is not a group owner.'))
795 return render_response(
796 template='im/astakosgroup_detail.html',
797 context_instance=get_context(request),
800 more_policies=m.group.has_undefined_policies
805 @signed_terms_required
808 def approve_member(request, membership):
811 realname = membership.person.realname
812 msg = _('%s has been successfully joined the group.' % realname)
813 messages.success(request, msg)
814 except BaseException, e:
816 msg = _('Something went wrong during %s\'s approval.' % realname)
817 messages.error(request, msg)
819 @signed_terms_required
822 def disapprove_member(request, membership):
824 membership.disapprove()
825 realname = membership.person.realname
826 msg = _('%s has been successfully removed from the group.' % realname)
827 messages.success(request, msg)
828 except BaseException, e:
830 msg = _('Something went wrong during %s\'s disapproval.' % realname)
831 messages.error(request, msg)
833 @signed_terms_required
835 def resource_list(request):
836 return render_response(
837 template='im/astakosuserquota_list.html',
838 context_instance=get_context(request),
839 quota=request.user.quota