diff --git a/docs/changelog.rst b/docs/changelog.rst index 919eb10..6864925 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,20 @@ Changelog ========= +* :release:`0.10.0 <2015-02-01>` +* :support:`-` move taskresults tests to tasksresults.tests and fix them +* :support:`-` cache result of get_hosting_package method of + gvawebcore.views.HostingPackageAndCustomerMixin to improve page loading + performance +* :feature:`-` add ability to add, list and delete SSH public keys assigned to + a hosting package's operating system user and change their comments +* :feature:`-` add ability to add SSH public keys for operating system users +* :support:`-` make tests in osusers.tests work again +* :support:`-` minor HTML improvements +* :support:`-` add API for gvafile task set_file_ssh_authorized_keys (requires + gvafile >= 0.5.0 on the fileserver side) +* :support:`-` update to Django 1.7.4 + * :release:`0.9.0 <2015-01-27>` * :feature:`-` setup nginx virtual host and PHP configuration for websites (requires gvaweb >= 0.1.0 on web server) diff --git a/docs/conf.py b/docs/conf.py index 8a8295e..f3502b5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -60,9 +60,9 @@ copyright = u'2014, 2015 Jan Dittberner' # built documents. # # The short X.Y version. -version = '0.9' +version = '0.10' # The full version, including alpha/beta/rc tags. -release = '0.9.0' +release = '0.10.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/gnuviechadmin/.coveragerc b/gnuviechadmin/.coveragerc index 1f6fac4..2ca7e1c 100644 --- a/gnuviechadmin/.coveragerc +++ b/gnuviechadmin/.coveragerc @@ -1,5 +1,5 @@ [run] -source = gnuviechadmin,managemails,osusers,domains +source = gnuviechadmin,managemails,osusers,domains,taskresults,gvawebcore,userdbs [report] omit = */migrations/*,*/tests/*.py,*/tests.py,gnuviechadmin/settings/local.py,gnuviechadmin/settings/production.py diff --git a/gnuviechadmin/fileservertasks/tasks.py b/gnuviechadmin/fileservertasks/tasks.py index 67ea3e4..c04d395 100644 --- a/gnuviechadmin/fileservertasks/tasks.py +++ b/gnuviechadmin/fileservertasks/tasks.py @@ -73,7 +73,7 @@ def create_file_mailbox(username, mailboxname): :param str username: the user name :param str mailboxname: the mailbox name - :raises GVAFileException: if the mailbox directory cannot be created + :raises Exception: if the mailbox directory cannot be created :return: the created mailbox directory name :rtype: str @@ -87,7 +87,7 @@ def delete_file_mailbox(username, mailboxname): :param str username: the user name :param str mailboxname: the mailbox name - :raises GVAFileException: if the mailbox directory cannot be deleted + :raises Exception: if the mailbox directory cannot be deleted :return: the deleted mailbox directory name :rtype: str @@ -118,3 +118,18 @@ def delete_file_website_hierarchy(username, sitename): :rtype: str """ + + +@shared_task +def set_file_ssh_authorized_keys(username, ssh_keys): + """ + This task sets the authorized keys for ssh logins. + + :param str username: the user name + :param list ssh_key: an ssh_key + :raises Exception: if the update of the creation or update of ssh + authorized_keys failed + :return: the name of the authorized_keys file + :rtype: str + + """ diff --git a/gnuviechadmin/gnuviechadmin/settings/base.py b/gnuviechadmin/gnuviechadmin/settings/base.py index f447d70..c7b59fb 100644 --- a/gnuviechadmin/gnuviechadmin/settings/base.py +++ b/gnuviechadmin/gnuviechadmin/settings/base.py @@ -273,10 +273,10 @@ INSTALLED_APPS = DJANGO_APPS + ALLAUTH_APPS + LOCAL_APPS MESSAGE_TAGS = { messages.DEBUG: '', - messages.ERROR: 'text-danger', - messages.INFO: 'text-info', - messages.SUCCESS: 'text-success', - messages.WARNING: 'text-warning', + messages.ERROR: 'alert-danger', + messages.INFO: 'alert-info', + messages.SUCCESS: 'alert-success', + messages.WARNING: 'alert-warning', } ########## END APP CONFIGURATION diff --git a/gnuviechadmin/gvawebcore/views.py b/gnuviechadmin/gvawebcore/views.py index 6e22e29..f6079bb 100644 --- a/gnuviechadmin/gvawebcore/views.py +++ b/gnuviechadmin/gvawebcore/views.py @@ -17,10 +17,14 @@ class HostingPackageAndCustomerMixin(object): hosting_package_kwarg = 'package' """Keyword argument used to find the hosting package in the URL.""" + hostingpackage = None + def get_hosting_package(self): - return get_object_or_404( - CustomerHostingPackage, - pk=int(self.kwargs[self.hosting_package_kwarg])) + if self.hostingpackage is None: + self.hostingpackage = get_object_or_404( + CustomerHostingPackage, + pk=int(self.kwargs[self.hosting_package_kwarg])) + return self.hostingpackage def get_customer_object(self): return self.get_hosting_package().customer diff --git a/gnuviechadmin/hostingpackages/views.py b/gnuviechadmin/hostingpackages/views.py index 75e7cb8..de6b029 100644 --- a/gnuviechadmin/hostingpackages/views.py +++ b/gnuviechadmin/hostingpackages/views.py @@ -121,6 +121,7 @@ class CustomerHostingPackageDetails(StaffOrSelfLoginRequiredMixin, DetailView): 'domains': context['hostingpackage'].domains.all(), 'mailboxes': context['hostingpackage'].mailboxes, }) + context['sshkeys'] = context['osuser'].sshpublickey_set.all() return context diff --git a/gnuviechadmin/ldaptasks/tasks.py b/gnuviechadmin/ldaptasks/tasks.py index d7fed5c..72acd8d 100644 --- a/gnuviechadmin/ldaptasks/tasks.py +++ b/gnuviechadmin/ldaptasks/tasks.py @@ -52,7 +52,7 @@ def create_ldap_user(username, uid, gid, gecos, homedir, shell, password): @shared_task -def set_ldap_user_password(self, username, password): +def set_ldap_user_password(username, password): """ This task sets the password of an existing :py:class:`LDAP user `. diff --git a/gnuviechadmin/locale/de/LC_MESSAGES/django.po b/gnuviechadmin/locale/de/LC_MESSAGES/django.po index f318895..ad27f0c 100644 --- a/gnuviechadmin/locale/de/LC_MESSAGES/django.po +++ b/gnuviechadmin/locale/de/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: gnuviechadmin\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-01-27 18:55+0100\n" -"PO-Revision-Date: 2015-01-27 19:06+0100\n" +"POT-Creation-Date: 2015-02-01 02:12+0100\n" +"PO-Revision-Date: 2015-02-01 02:29+0100\n" "Last-Translator: Jan Dittberner \n" "Language-Team: Jan Dittberner \n" "Language: de\n" @@ -464,6 +464,10 @@ msgstr "" "Angemeldet als %(user_display)s" +#: templates/base.html:87 +msgid "Close" +msgstr "Schließen" + #: templates/dashboard/index.html:3 msgid "Welcome" msgstr "Willkommen" @@ -524,14 +528,15 @@ msgid "Mailboxes" msgstr "Postfächer" #: templates/dashboard/user_dashboard.html:18 -#: templates/hostingpackages/customerhostingpackage_detail.html:176 +#: templates/hostingpackages/customerhostingpackage_detail.html:177 msgid "Databases" msgstr "Datenbanken" #: templates/dashboard/user_dashboard.html:19 -#: templates/hostingpackages/customerhostingpackage_detail.html:86 -#: templates/hostingpackages/customerhostingpackage_detail.html:149 -#: templates/hostingpackages/customerhostingpackage_detail.html:184 +#: templates/hostingpackages/customerhostingpackage_detail.html:87 +#: templates/hostingpackages/customerhostingpackage_detail.html:150 +#: templates/hostingpackages/customerhostingpackage_detail.html:185 +#: templates/osusers/sshpublickey_list.html:27 msgid "Actions" msgstr "Aktionen" @@ -666,6 +671,13 @@ msgstr "SFTP-Benutzername" msgid "SSH/SFTP username" msgstr "SSH/SFTP-Benutzername" +#: templates/hostingpackages/customerhostingpackage_detail.html:42 +#, python-format +msgid "There is an SSH public key set for this user." +msgid_plural "There are %(counter)s SSH public keys set for this user." +msgstr[0] "Es wurde ein SSH-Schlüssel für diesen Nutzer hinterlegt." +msgstr[1] "Es wurden %(counter)s SSH-Schlüssel für diesen Nutzer hinterlegt." + #: templates/hostingpackages/customerhostingpackage_detail.html:43 msgid "Upload server" msgstr "Uploadserver" @@ -706,126 +718,137 @@ msgstr "SFTP-Passwort setzen" msgid "Set SSH/SFTP password" msgstr "SSH/SFTP-Passwort setzen" -#: templates/hostingpackages/customerhostingpackage_detail.html:78 +#: templates/hostingpackages/customerhostingpackage_detail.html:71 +msgid "Add an SSH public key that can be used as an alternative for password" +msgstr "" +"Einen SSH-Schlüssel, der als Alternative zum Passwort genutzt werden kann, " +"hinzufügen" + +#: templates/hostingpackages/customerhostingpackage_detail.html:71 +#: templates/osusers/sshpublickey_list.html:46 +msgid "Add SSH public key" +msgstr "SSH-Schlüssel hinzufügen" + +#: templates/hostingpackages/customerhostingpackage_detail.html:79 msgid "Domains" msgstr "Domains" -#: templates/hostingpackages/customerhostingpackage_detail.html:83 +#: templates/hostingpackages/customerhostingpackage_detail.html:84 msgid "Domain name" msgstr "Domainname" -#: templates/hostingpackages/customerhostingpackage_detail.html:84 -#: templates/hostingpackages/customerhostingpackage_detail.html:147 +#: templates/hostingpackages/customerhostingpackage_detail.html:85 +#: templates/hostingpackages/customerhostingpackage_detail.html:148 msgid "Mail addresses" msgstr "E-Mailadressen" -#: templates/hostingpackages/customerhostingpackage_detail.html:85 +#: templates/hostingpackages/customerhostingpackage_detail.html:86 msgid "Websites" msgstr "Webauftritte" -#: templates/hostingpackages/customerhostingpackage_detail.html:86 +#: templates/hostingpackages/customerhostingpackage_detail.html:87 msgid "Domain actions" msgstr "Domainaktionen" -#: templates/hostingpackages/customerhostingpackage_detail.html:97 +#: templates/hostingpackages/customerhostingpackage_detail.html:98 msgid "Edit mail address targets" msgstr "E-Mailadressziele bearbeiten" -#: templates/hostingpackages/customerhostingpackage_detail.html:98 +#: templates/hostingpackages/customerhostingpackage_detail.html:99 msgid "Delete mail address" msgstr "E-Mailadresse löschen" -#: templates/hostingpackages/customerhostingpackage_detail.html:103 -#: templates/hostingpackages/customerhostingpackage_detail.html:115 +#: templates/hostingpackages/customerhostingpackage_detail.html:104 +#: templates/hostingpackages/customerhostingpackage_detail.html:116 msgid "None" msgstr "Keine" -#: templates/hostingpackages/customerhostingpackage_detail.html:110 +#: templates/hostingpackages/customerhostingpackage_detail.html:111 msgid "Delete website" msgstr "Webauftritt löschen" -#: templates/hostingpackages/customerhostingpackage_detail.html:119 +#: templates/hostingpackages/customerhostingpackage_detail.html:120 msgid "Add mail address" msgstr "E-Mailadresse hinzufügen" -#: templates/hostingpackages/customerhostingpackage_detail.html:122 +#: templates/hostingpackages/customerhostingpackage_detail.html:123 msgid "Add website" msgstr "Webauftritt anlegen" -#: templates/hostingpackages/customerhostingpackage_detail.html:130 +#: templates/hostingpackages/customerhostingpackage_detail.html:131 msgid "There are no domains assigned to this hosting package yet." msgstr "Diesem Paket sind noch keine Domains zugeordnet." -#: templates/hostingpackages/customerhostingpackage_detail.html:133 +#: templates/hostingpackages/customerhostingpackage_detail.html:134 msgid "Add domain" msgstr "Domain hinzufügen" -#: templates/hostingpackages/customerhostingpackage_detail.html:141 +#: templates/hostingpackages/customerhostingpackage_detail.html:142 msgid "E-Mail-Accounts" msgstr "E-Mailkonten" -#: templates/hostingpackages/customerhostingpackage_detail.html:146 +#: templates/hostingpackages/customerhostingpackage_detail.html:147 msgid "Mailbox" msgstr "Postfach" -#: templates/hostingpackages/customerhostingpackage_detail.html:148 -#: templates/hostingpackages/customerhostingpackage_detail.html:157 +#: templates/hostingpackages/customerhostingpackage_detail.html:149 +#: templates/hostingpackages/customerhostingpackage_detail.html:158 msgid "Active" msgstr "Aktiv" -#: templates/hostingpackages/customerhostingpackage_detail.html:149 +#: templates/hostingpackages/customerhostingpackage_detail.html:150 msgid "Mailbox actions" msgstr "Postfachaktionen" -#: templates/hostingpackages/customerhostingpackage_detail.html:157 +#: templates/hostingpackages/customerhostingpackage_detail.html:158 msgid "inactive" msgstr "inaktiv" -#: templates/hostingpackages/customerhostingpackage_detail.html:159 +#: templates/hostingpackages/customerhostingpackage_detail.html:160 msgid "Set mailbox password" msgstr "Postfachpasswort setzen" -#: templates/hostingpackages/customerhostingpackage_detail.html:165 +#: templates/hostingpackages/customerhostingpackage_detail.html:166 msgid "There are no mailboxes assigned to this hosting package yet." msgstr "Diesem Hostingpaket sind noch keine Postfächer zugeordnet." -#: templates/hostingpackages/customerhostingpackage_detail.html:168 +#: templates/hostingpackages/customerhostingpackage_detail.html:169 msgid "Add mailbox" msgstr "Postfach hinzufügen" -#: templates/hostingpackages/customerhostingpackage_detail.html:181 +#: templates/hostingpackages/customerhostingpackage_detail.html:182 msgid "Database name" msgstr "Datenbankname" -#: templates/hostingpackages/customerhostingpackage_detail.html:182 +#: templates/hostingpackages/customerhostingpackage_detail.html:183 msgid "Database user" msgstr "Datenbanknutzer" -#: templates/hostingpackages/customerhostingpackage_detail.html:183 +#: templates/hostingpackages/customerhostingpackage_detail.html:184 msgid "Database type" msgstr "Datenbanktyp" -#: templates/hostingpackages/customerhostingpackage_detail.html:183 +#: templates/hostingpackages/customerhostingpackage_detail.html:184 msgid "Type" msgstr "Typ" -#: templates/hostingpackages/customerhostingpackage_detail.html:184 +#: templates/hostingpackages/customerhostingpackage_detail.html:185 msgid "Database actions" msgstr "Datenbankaktionen" -#: templates/hostingpackages/customerhostingpackage_detail.html:194 +#: templates/hostingpackages/customerhostingpackage_detail.html:195 msgid "Set database user password" msgstr "Datenbanknutzerpasswort setzen" -#: templates/hostingpackages/customerhostingpackage_detail.html:195 +#: templates/hostingpackages/customerhostingpackage_detail.html:196 msgid "Delete database" msgstr "Datenbank löschen" -#: templates/hostingpackages/customerhostingpackage_detail.html:202 +#: templates/hostingpackages/customerhostingpackage_detail.html:203 msgid "There are no databases assigned to this hosting package yet." msgstr "Diesem Hostingpaket sind noch keine Datenbanken zugeordnet." -#: templates/hostingpackages/customerhostingpackage_detail.html:205 +#: templates/hostingpackages/customerhostingpackage_detail.html:206 msgid "Add database" msgstr "Datenbank hinzufügen" @@ -856,12 +879,14 @@ msgid "Do you really want to delete the mail address %(mailaddress)s?" msgstr "Wollen Sie die E-Mailadresse %(mailaddress)s wirklich löschen?" #: templates/managemails/mailaddress_confirm_delete.html:28 +#: templates/osusers/sshpublickey_confirm_delete.html:30 #: templates/userdbs/userdatabase_confirm_delete.html:29 #: templates/websites/website_confirm_delete.html:29 msgid "Yes, do it!" msgstr "Ja, so soll es sein!" #: templates/managemails/mailaddress_confirm_delete.html:29 +#: templates/osusers/sshpublickey_confirm_delete.html:31 #: templates/userdbs/userdatabase_confirm_delete.html:30 #: templates/websites/website_confirm_delete.html:30 msgid "Cancel" @@ -938,6 +963,177 @@ msgstr "Bitte geben Sie das neue Passwort für Ihr Postfach ein." msgid "Please specify the new password for the mailbox." msgstr "Bitte geben Sie das neue Passwort für das Postfach ein." +#: templates/osusers/sshpublickey_confirm_delete.html:6 +#, python-format +msgid "Delete SSH Public Key for Operating System User %(osuser)s" +msgstr "SSH-Schlüssel des Betriebssystemnutzers %(osuser)s löschen" + +#: templates/osusers/sshpublickey_confirm_delete.html:8 +#, python-format +msgid "" +"Delete SSH Public Key for Operating System User %(osuser)s of Customer " +"%(full_name)s" +msgstr "" +"SSH-Schlüssel des Betriebssystemnutzers %(osuser)s des Kunden %(full_name)s " +"löschen" + +#: templates/osusers/sshpublickey_confirm_delete.html:14 +#, python-format +msgid "" +"Delete SSH Public Key for Operating System User %(osuser)s" +msgstr "" +"SSH-Schlüssel löschen für Betriebssystemnutzer %(osuser)s" + +#: templates/osusers/sshpublickey_confirm_delete.html:16 +#, python-format +msgid "" +"Delete SSH Public Key for Operating System User %(osuser)s of " +"Customer %(full_name)s" +msgstr "" +"SSH-Schlüssel löschen von Betriebssystemnutzer %(osuser)s des Kunden " +"%(full_name)s" + +#: templates/osusers/sshpublickey_confirm_delete.html:23 +#, python-format +msgid "Do you really want to delete the %(algorithm)s SSH public key?" +msgstr "Wollen Sie den %(algorithm)s-SSH-Schlüssel wirklich löschen?" + +#: templates/osusers/sshpublickey_confirm_delete.html:26 +msgid "" +"When you confirm the deletion of this key you will no longer be able to use " +"the corresponding private key for authentication." +msgstr "" +"Wenn Sie die Löschung dieses Schlüssels bestätigen, werden Sie den " +"dazugehörigen privaten Schlüssel nicht weiter für die Anmeldung verwenden " +"können." + +#: templates/osusers/sshpublickey_confirm_delete.html:31 +msgid "Cancel and go back to the SSH key list" +msgstr "Abbrechen und zurückgehen zur Liste der SSH-Schlüssel" + +#: templates/osusers/sshpublickey_create.html:6 +#, python-format +msgid "Add new SSH Public Key for Operating System User %(osuser)s" +msgstr "Neuen SSH-Schlüssel für Betriebssystemnutzer %(osuser)s hinterlegen" + +#: templates/osusers/sshpublickey_create.html:8 +#, python-format +msgid "" +"Add a new SSH Public Key for Operating System User %(osuser)s of Customer " +"%(full_name)s" +msgstr "" +"Neuen SSH-Schlüssel für Betriebssystemnutzer %(osuser)s des Kunden " +"%(full_name)s hinterlegen" + +#: templates/osusers/sshpublickey_create.html:14 +#, python-format +msgid "" +"Add new SSH Public Key for Operating System User %(osuser)s" +msgstr "" +"Neuen SSH-Schlüssel hinterlegen für Betriebssystemnutzer %(osuser)s" + +#: templates/osusers/sshpublickey_create.html:16 +#, python-format +msgid "" +"Add a new SSH Public Key for Operating System User %(osuser)s of " +"Customer %(full_name)s" +msgstr "" +"Neuen SSH-Schlüssel hinterlegen für Betriebssystemnutzer %(osuser)s " +"der Kunden %(full_name)s" + +#: templates/osusers/sshpublickey_edit_comment.html:6 +#, python-format +msgid "Edit Comment of SSH Public Key for Operating System User %(osuser)s" +msgstr "" +"Kommentar eines SSH-Schlüssels für Betriebssystemnutzer %(osuser)s ändern" + +#: templates/osusers/sshpublickey_edit_comment.html:8 +#, python-format +msgid "" +"Edit Comment of SSH Public Key for Operating System User %(osuser)s of " +"Customer %(full_name)s" +msgstr "" +"Kommentar des SSH-Schlüssels des Betriebssystemnutzers %(osuser)s des Kunden " +"%(full_name)s ändern" + +#: templates/osusers/sshpublickey_edit_comment.html:14 +#, python-format +msgid "" +"Edit Comment of Public Key for Operating System User %(osuser)s" +msgstr "" +"Kommentar eines SSH-Schlüssels ändern für Betriebssystemnutzer " +"%(osuser)s" + +#: templates/osusers/sshpublickey_edit_comment.html:16 +#, python-format +msgid "" +"Edit Comment of SSH Public Key for Operating System User %(osuser)s " +"of Customer %(full_name)s" +msgstr "" +"Kommentar des SSH-Schlüssels ändern für Betriebssystemnutzer " +"%(osuser)s des Kunden %(full_name)s" + +#: templates/osusers/sshpublickey_list.html:6 +#, python-format +msgid "SSH Public Keys for Operating System User %(osuser)s" +msgstr "SSH-Schlüssel für Betriebssystemnutzer %(osuser)s" + +#: templates/osusers/sshpublickey_list.html:8 +#, python-format +msgid "" +"SSH Public Keys for Operating System User %(osuser)s of Customer " +"%(full_name)s" +msgstr "" +"SSH-Schlüssel des Betriebssystemnutzers %(osuser)s des Kunden %(full_name)s" + +#: templates/osusers/sshpublickey_list.html:14 +#, python-format +msgid "SSH Public Keys for Operating System User %(osuser)s" +msgstr "SSH-Schlüssel für Betriebssystemnutzer %(osuser)s" + +#: templates/osusers/sshpublickey_list.html:16 +#, python-format +msgid "" +"SSH Public Keys for Operating System User %(osuser)s of Customer " +"%(full_name)s" +msgstr "" +"SSH-Schlüssel des Betriebssystemnutzers %(osuser)s des Kunden " +"%(full_name)s" + +#: templates/osusers/sshpublickey_list.html:25 +msgid "Algorithm" +msgstr "Algorithmus" + +#: templates/osusers/sshpublickey_list.html:26 +msgid "Comment" +msgstr "Kommentar" + +#: templates/osusers/sshpublickey_list.html:27 +msgid "SSH public key actions" +msgstr "Aktionen für SSH-Schlüssel" + +#: templates/osusers/sshpublickey_list.html:36 +msgid "Delete this SSH public key" +msgstr "Diesen SSH-Schlüssel löschen" + +#: templates/osusers/sshpublickey_list.html:36 +msgid "Delete" +msgstr "Löschen" + +#: templates/osusers/sshpublickey_list.html:37 +msgid "Edit this SSH public key's comment" +msgstr "Den Kommentar dieses SSH-Schlüssels ändern" + +#: templates/osusers/sshpublickey_list.html:37 +msgid "Edit Comment" +msgstr "Kommentar ändern" + +#: templates/osusers/sshpublickey_list.html:44 +msgid "There are now SSH public keys set for this operating system user yet." +msgstr "Diesem Betriebssytemnutzer wurden noch keine SSH-Schlüssel zugeordnet." + #: templates/osusers/user_setpassword.html:5 #: templates/osusers/user_setpassword.html:13 #, python-format diff --git a/gnuviechadmin/osusers/admin.py b/gnuviechadmin/osusers/admin.py index 6fec6e7..91df5a8 100644 --- a/gnuviechadmin/osusers/admin.py +++ b/gnuviechadmin/osusers/admin.py @@ -3,16 +3,24 @@ This module contains the Django admin classes of the :py:mod:`osusers` app. """ from django import forms -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _ from django.contrib import admin +from fileservertasks.tasks import set_file_ssh_authorized_keys from gvawebcore.forms import ( PASSWORD_MISMATCH_ERROR ) +from taskresults.models import TaskResult + +from .forms import ( + INVALID_SSH_PUBLIC_KEY, + DUPLICATE_SSH_PUBLIC_KEY_FOR_USER, +) from .models import ( AdditionalGroup, Group, Shadow, + SshPublicKey, User, ) @@ -216,5 +224,158 @@ class GroupAdmin(admin.ModelAdmin): return actions +class SshPublicKeyCreationForm(forms.ModelForm): + """ + A form for creating :py:class:`SSH public keys + `. + + """ + publickeytext = forms.CharField( + label=_('Key text'), widget=forms.Textarea, + help_text=_('A SSH public key in either OpenSSH or RFC 4716 format')) + + class Meta: + model = SshPublicKey + fields = ['user'] + + def clean_publickeytext(self): + keytext = self.cleaned_data.get('publickeytext') + try: + SshPublicKey.objects.parse_keytext(keytext) + except: + raise forms.ValidationError(INVALID_SSH_PUBLIC_KEY) + return keytext + + def clean(self): + user = self.cleaned_data.get('user') + keytext = self.cleaned_data.get('publickeytext') + if user and keytext: + alg, data, comment = SshPublicKey.objects.parse_keytext(keytext) + if SshPublicKey.objects.filter( + user=user, algorithm=alg, data=data + ).exists(): + self.add_error( + 'publickeytext', + forms.ValidationError(DUPLICATE_SSH_PUBLIC_KEY_FOR_USER)) + + def save(self, commit=True): + """ + Save the provided ssh public key in properly split format. + + :param boolean commit: whether to save the created public key + :return: ssh public key instance + :rtype: :py:class:`osusers.models.SshPublicKey` + + """ + algorithm, keydata, comment = SshPublicKey.objects.parse_keytext( + self.cleaned_data.get('publickeytext')) + self.instance.algorithm = algorithm + self.instance.data = keydata + self.instance.comment = comment + return super(SshPublicKeyCreationForm, self).save(commit) + + +class SshPublicKeyAdmin(admin.ModelAdmin): + """ + Admin class for working with :py:class:`SSH public keys + `. + + """ + actions = ['perform_delete_selected'] + add_form = SshPublicKeyCreationForm + list_display = ['user', 'algorithm', 'comment'] + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('user', 'publickeytext')}), + ) + + def get_form(self, request, obj=None, **kwargs): + """ + Use special form for ssh public key creation." + + :param request: the current HTTP request + :param obj: either a :py:class:`SshPublicKey + ` instance or None for a new SSH + public key + :param kwargs: keyword arguments to be passed to + :py:meth:`django.contrib.admin.ModelAdmin.get_form` + :return: form instance + + """ + defaults = {} + if obj is None: + defaults.update({ + 'form': self.add_form, + 'fields': admin.options.flatten_fieldsets(self.add_fieldsets), + }) + defaults.update(kwargs) + return super(SshPublicKeyAdmin, self).get_form( + request, obj, **defaults) + + def get_readonly_fields(self, request, obj=None): + """ + Make sure that algorithm and data of SSH public keys are not editable. + + :param request: the current HTTP request + :param obj: either a :py:class:`SshPublicKey + ` instance or None for a new SSH + public key + :return: a list of fields + :rtype: list + + """ + if obj: + return ['algorithm', 'data'] + return [] + + def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected ssh keys. + + This action makes sure that the ssh keys of all users affected by the + current deletion are refreshed on the file server. + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected ssh keys + + """ + users = set([ + item['user'] for item in + queryset.values('user').distinct() + ]) + queryset.delete() + for user in users: + TaskResult.objects.create_task_result( + set_file_ssh_authorized_keys.delay( + User.objects.get(uid=user).username, [ + str(key) for key in SshPublicKey.objects.filter( + user_id=user) + ]), + 'set_file_ssh_authorized_keys' + ) + perform_delete_selected.short_description = _( + 'Delete selected SSH public keys') + + def get_actions(self, request): + """ + Get the available actions for SSH public keys. + + This overrides the default behavior to remove the default + `delete_selected` action. + + :param request: the current HTTP request + :return: list of actions + :rtype: list + + """ + actions = super(SshPublicKeyAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + admin.site.register(Group, GroupAdmin) admin.site.register(User, UserAdmin) +admin.site.register(SshPublicKey, SshPublicKeyAdmin) diff --git a/gnuviechadmin/osusers/forms.py b/gnuviechadmin/osusers/forms.py index 73faa02..ed1a6c1 100644 --- a/gnuviechadmin/osusers/forms.py +++ b/gnuviechadmin/osusers/forms.py @@ -13,7 +13,14 @@ from crispy_forms.layout import Submit from gvawebcore.forms import PasswordModelFormMixin -from .models import User +from .models import ( + SshPublicKey, + User, +) + +INVALID_SSH_PUBLIC_KEY = _('Invalid SSH public key data format.') +DUPLICATE_SSH_PUBLIC_KEY_FOR_USER = _( + 'This SSH public key is already assigned to this user.') class ChangeOsUserPasswordForm(PasswordModelFormMixin, forms.ModelForm): @@ -43,3 +50,85 @@ class ChangeOsUserPasswordForm(PasswordModelFormMixin, forms.ModelForm): """ self.instance.set_password(self.cleaned_data['password1']) return super(ChangeOsUserPasswordForm, self).save(commit=commit) + + +class AddSshPublicKeyForm(forms.ModelForm): + """ + A form for creating :py:class:`SSH public keys + `. + + """ + publickeytext = forms.CharField( + label=_('Key text'), widget=forms.Textarea, + help_text=_('A SSH public key in either OpenSSH or RFC 4716 format')) + + class Meta: + model = SshPublicKey + fields = [] + + def __init__(self, *args, **kwargs): + hosting_package = kwargs.pop('hostingpackage') + self.osuser = hosting_package.osuser + super(AddSshPublicKeyForm, self).__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_action = reverse( + 'add_ssh_key', kwargs={'package': hosting_package.id}) + self.helper.add_input(Submit('submit', _('Add SSH public key'))) + + def clean_publickeytext(self): + keytext = self.cleaned_data.get('publickeytext') + try: + SshPublicKey.objects.parse_keytext(keytext) + except: + raise forms.ValidationError(INVALID_SSH_PUBLIC_KEY) + return keytext + + def clean(self): + keytext = self.cleaned_data.get('publickeytext') + alg, data, comment = SshPublicKey.objects.parse_keytext(keytext) + if SshPublicKey.objects.filter( + user=self.osuser, algorithm=alg, data=data + ).exists(): + self.add_error( + 'publickeytext', + forms.ValidationError(DUPLICATE_SSH_PUBLIC_KEY_FOR_USER) + ) + + def save(self, commit=True): + """ + Save the provided ssh public key in properly split format. + + :param boolean commit: whether to save the created public key + :return: ssh public key instance + :rtype: :py:class:`osusers.models.SshPublicKey` + + """ + algorithm, keydata, comment = SshPublicKey.objects.parse_keytext( + self.cleaned_data.get('publickeytext')) + self.instance.user = self.osuser + self.instance.algorithm = algorithm + self.instance.data = keydata + self.instance.comment = comment + return super(AddSshPublicKeyForm, self).save(commit) + + +class EditSshPublicKeyCommentForm(forms.ModelForm): + """ + A form for editing :py:class:`SSH public key + ` comment fields. + + """ + class Meta: + model = SshPublicKey + fields = ['comment'] + + def __init__(self, *args, **kwargs): + hosting_package = kwargs.pop('hostingpackage') + self.osuser = hosting_package.osuser + super(EditSshPublicKeyCommentForm, self).__init__(*args, **kwargs) + self.fields['comment'].widget = forms.TextInput() + self.helper = FormHelper() + self.helper.form_action = reverse( + 'edit_ssh_key_comment', + kwargs={'package': hosting_package.id, 'pk': self.instance.id}) + self.helper.add_input(Submit('submit', _('Change Comment'))) diff --git a/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po b/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po index 0e6cc3f..b2723bd 100644 --- a/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po +++ b/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: osusers\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-01-27 18:55+0100\n" -"PO-Revision-Date: 2015-01-24 18:25+0100\n" +"POT-Creation-Date: 2015-02-01 02:12+0100\n" +"PO-Revision-Date: 2015-02-01 02:17+0100\n" "Last-Translator: Jan Dittberner \n" "Language-Team: Jan Dittberner \n" "Language: de\n" @@ -19,128 +19,158 @@ msgstr "" "X-Generator: Poedit 1.6.10\n" "X-Poedit-SourceCharset: UTF-8\n" -#: osusers/admin.py:45 +#: osusers/admin.py:53 msgid "Password" msgstr "Passwort" -#: osusers/admin.py:49 +#: osusers/admin.py:57 msgid "Password (again)" msgstr "Passwortwiederholung" -#: osusers/admin.py:158 +#: osusers/admin.py:166 msgid "Delete selected users" msgstr "Ausgewählte Nutzer löschen" -#: osusers/admin.py:199 +#: osusers/admin.py:207 msgid "Delete selected groups" msgstr "Ausgewählte Gruppen löschen" +#: osusers/admin.py:234 osusers/forms.py:62 +msgid "Key text" +msgstr "Schlüsseltext" + +#: osusers/admin.py:235 osusers/forms.py:63 +msgid "A SSH public key in either OpenSSH or RFC 4716 format" +msgstr "" +"Öffentlicher Teil eines SSH-Schlüssels entweder im OpenSSH- oder im RFC-4716-" +"Format" + +#: osusers/admin.py:359 +msgid "Delete selected SSH public keys" +msgstr "Ausgewählte SSH-Schlüssel löschen" + #: osusers/apps.py:17 msgid "Operating System Users and Groups" msgstr "Betriebssystemnutzer- und Gruppen" -#: osusers/forms.py:33 +#: osusers/forms.py:21 +msgid "Invalid SSH public key data format." +msgstr "Ungültiges Format für den öffentlichen Teil eines SSH-Schlüssels." + +#: osusers/forms.py:23 +msgid "This SSH public key is already assigned to this user." +msgstr "Dieser SSH-Schlüssel wurde diesem Nutzer bereits zugeordnet." + +#: osusers/forms.py:40 msgid "Set password" msgstr "Passwort setzen" -#: osusers/models.py:47 +#: osusers/forms.py:76 +msgid "Add SSH public key" +msgstr "SSH-Schlüssel hinzufügen" + +#: osusers/forms.py:134 +msgid "Change Comment" +msgstr "Kommentar ändern" + +#: osusers/models.py:50 msgid "You can not use a user's primary group." msgstr "Sie können nicht die primäre Gruppe des Nutzers verwenden." -#: osusers/models.py:77 +#: osusers/models.py:80 msgid "Group name" msgstr "Gruppenname" -#: osusers/models.py:79 +#: osusers/models.py:82 msgid "Group ID" msgstr "Gruppen-ID" -#: osusers/models.py:80 +#: osusers/models.py:83 msgid "Description" msgstr "Beschreibung" -#: osusers/models.py:82 +#: osusers/models.py:85 msgid "Group password" msgstr "Gruppenpasswort" -#: osusers/models.py:87 osusers/models.py:221 +#: osusers/models.py:90 osusers/models.py:224 msgid "Group" msgstr "Gruppe" -#: osusers/models.py:88 +#: osusers/models.py:91 msgid "Groups" msgstr "Gruppen" -#: osusers/models.py:218 +#: osusers/models.py:221 msgid "User name" msgstr "Nutzername" -#: osusers/models.py:220 +#: osusers/models.py:223 msgid "User ID" msgstr "Nutzer-ID" -#: osusers/models.py:222 +#: osusers/models.py:225 msgid "Gecos field" msgstr "GECOS-Feld" -#: osusers/models.py:223 +#: osusers/models.py:226 msgid "Home directory" msgstr "Home-Verzeichnis" -#: osusers/models.py:224 +#: osusers/models.py:227 msgid "Login shell" msgstr "Loginshell" -#: osusers/models.py:230 osusers/models.py:370 +#: osusers/models.py:233 osusers/models.py:373 osusers/models.py:566 msgid "User" msgstr "Nutzer" -#: osusers/models.py:231 +#: osusers/models.py:234 msgid "Users" msgstr "Nutzer" -#: osusers/models.py:371 +#: osusers/models.py:374 msgid "Encrypted password" msgstr "Verschlüsseltes Passwort" -#: osusers/models.py:373 +#: osusers/models.py:376 msgid "Date of last change" msgstr "Datum der letzten Änderung" -#: osusers/models.py:374 +#: osusers/models.py:377 msgid "This is expressed in days since Jan 1, 1970" msgstr "Ausgedrückt als Tage seit dem 1. Januar 1970" -#: osusers/models.py:377 +#: osusers/models.py:380 msgid "Minimum age" msgstr "Minimales Alter" -#: osusers/models.py:378 +#: osusers/models.py:381 msgid "Minimum number of days before the password can be changed" msgstr "Minmale Anzahl von Tagen bevor das Passwort geändert werden kann" -#: osusers/models.py:382 +#: osusers/models.py:385 msgid "Maximum age" msgstr "Maximales Alter" -#: osusers/models.py:383 +#: osusers/models.py:386 msgid "Maximum number of days after which the password has to be changed" msgstr "" "Maximale Anzahl von Tagen, nach denen das Passwort geändert werden muss" -#: osusers/models.py:387 +#: osusers/models.py:390 msgid "Grace period" msgstr "Duldungsperiode" -#: osusers/models.py:388 +#: osusers/models.py:391 msgid "The number of days before the password is going to expire" msgstr "Anzahl von Tagen nach denen das Passwort verfällt" -#: osusers/models.py:392 +#: osusers/models.py:395 msgid "Inactivity period" msgstr "Inaktivitätsperiode" -#: osusers/models.py:393 +#: osusers/models.py:396 msgid "" "The number of days after the password has expired during which the password " "should still be accepted" @@ -148,36 +178,65 @@ msgstr "" "Die Anzahl von Tagen für die ein verfallenes Passwort noch akzeptiert werden " "soll" -#: osusers/models.py:397 +#: osusers/models.py:400 msgid "Account expiration date" msgstr "Kontoverfallsdatum" -#: osusers/models.py:398 +#: osusers/models.py:401 msgid "" "The date of expiration of the account, expressed as number of days since Jan " "1, 1970" msgstr "Kontoverfallsdatum in Tagen seit dem 1. Januar 1970" -#: osusers/models.py:405 +#: osusers/models.py:408 msgid "Shadow password" msgstr "Shadow-Passwort" -#: osusers/models.py:406 +#: osusers/models.py:409 msgid "Shadow passwords" msgstr "Shadow-Passwörter" -#: osusers/models.py:432 +#: osusers/models.py:435 msgid "Additional group" msgstr "Weitere Gruppe" -#: osusers/models.py:433 +#: osusers/models.py:436 msgid "Additional groups" msgstr "Weitere Gruppen" -#: osusers/views.py:42 +#: osusers/models.py:567 +msgid "Algorithm" +msgstr "Algorithmus" + +#: osusers/models.py:568 +msgid "Key bytes" +msgstr "Schlüsselbytes" + +#: osusers/models.py:569 +msgid "Base64 encoded key bytes" +msgstr "Base64-kodierte Schlüsselbytes" + +#: osusers/models.py:570 +msgid "Comment" +msgstr "Kommentar" + +#: osusers/models.py:575 +msgid "SSH public key" +msgstr "Öffentlicher SSH-Schlüssel" + +#: osusers/models.py:576 +msgid "SSH public keys" +msgstr "Öffentliche SSH-Schlüssel" + +#: osusers/views.py:56 #, python-brace-format msgid "New password for {username} has been set successfully." msgstr "Für {username} wurde erfolgreich ein neues Passwort gesetzt." +#: osusers/views.py:92 +#, python-brace-format +msgid "Successfully added new {algorithm} SSH public key" +msgstr "Neuer {algorithm}-SSH-Schlüssel erfolgreich hinzugefügt" + #~ msgid "Passwords don't match" #~ msgstr "Passwörter stimmen nicht überein" diff --git a/gnuviechadmin/osusers/migrations/0005_auto_20150131_2009.py b/gnuviechadmin/osusers/migrations/0005_auto_20150131_2009.py new file mode 100644 index 0000000..809f1a2 --- /dev/null +++ b/gnuviechadmin/osusers/migrations/0005_auto_20150131_2009.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('osusers', '0004_auto_20150104_1751'), + ] + + operations = [ + migrations.CreateModel( + name='SshPublicKey', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('algorithm', models.CharField(max_length=20, verbose_name='Algorithm')), + ('data', models.TextField(help_text='Base64 encoded key bytes', verbose_name='Key bytes')), + ('comment', models.TextField(verbose_name='Comment', blank=True)), + ('user', models.ForeignKey(verbose_name='User', to='osusers.User')), + ], + options={ + 'verbose_name': 'SSH public key', + 'verbose_name_plural': 'SSH public keys', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='sshpublickey', + unique_together=set([('user', 'algorithm', 'data')]), + ), + ] diff --git a/gnuviechadmin/osusers/models.py b/gnuviechadmin/osusers/models.py index 2f62463..ddde5e5 100644 --- a/gnuviechadmin/osusers/models.py +++ b/gnuviechadmin/osusers/models.py @@ -4,9 +4,11 @@ This module defines the database models of operating system users. """ from __future__ import unicode_literals +import base64 from datetime import date import logging import os +import six from django.db import models, transaction from django.conf import settings @@ -35,6 +37,7 @@ from ldaptasks.tasks import ( from fileservertasks.tasks import ( delete_file_mail_userdir, delete_file_sftp_userdir, + set_file_ssh_authorized_keys, setup_file_mail_userdir, setup_file_sftp_userdir, ) @@ -477,3 +480,124 @@ class AdditionalGroup(TimeStampedModel, models.Model): 'remove_ldap_user_from_group' ) super(AdditionalGroup, self).delete(*args, **kwargs) + + +class SshPublicKeyManager(models.Manager): + """ + Default manager for :py:class:`SSH public key + ` instances. + + """ + + def parse_keytext(self, keytext): + """ + Parse a SSH public key in OpenSSH or :rfc:`4716` format into its + components algorithm, key data and comment. + + :param str keytext: key text + :return: triple of algorithm name, key data and comment + :rtype: triple of str + + """ + if keytext.startswith('---- BEGIN SSH2 PUBLIC KEY ----'): + comment = '' + data = '' + continued = '' + headers = {} + for line in keytext.splitlines(): + if line == '---- BEGIN SSH2 PUBLIC KEY ----': + continue + elif ':' in line: # a header line + header_tag, header_value = [ + item.strip() for item in line.split(':', 1)] + if header_value.endswith('\\'): + continued = header_value[:-1] + else: + headers[header_tag.lower()] = header_value + elif continued: + if line.endswith('\\'): + continued += line[:-1] + continue + header_value = continued + line + headers[header_tag.lower()] = header_value + continued = '' + elif line == '---- END SSH2 PUBLIC KEY ----': + break + elif line: # ignore empty lines + data += line + if 'comment' in headers: + comment = headers['comment'] + else: + parts = keytext.split() + if len(parts) > 3: + raise ValueError("unsupported key format") + data = parts[1] + comment = len(parts) == 3 and parts[2] or "" + keybytes = base64.b64decode(data) + parts = keybytes.split(b'\x00' * 3) + alglength = six.byte2int(parts[1]) + algname = parts[1][1:1+alglength] + return algname, data, comment + + def create_ssh_public_key(self, user, keytext): + """ + Create a new :py:class:`SSH public key ` + for a user from the given key text representation. The text can be + either in openssh format or :rfc:`4716` format. + + :param user: :py:class:`operating system user ` + :param str keytext: key text + :return: public key + :retype: :py:class:`osusers.models.SshPublicKey` + + """ + algorithm, data, comment = self.parse_keytext(keytext) + return self.create( + user=user, algorithm=algorithm, data=data, comment=comment) + + +@python_2_unicode_compatible +class SshPublicKey(TimeStampedModel): + """ + This entity class represents single SSH keys for an :py:class:`operating + system user `. + + """ + user = models.ForeignKey(User, verbose_name=_('User')) + algorithm = models.CharField(_('Algorithm'), max_length=20) + data = models.TextField(_('Key bytes'), + help_text=_('Base64 encoded key bytes')) + comment = models.TextField(_('Comment'), blank=True) + + objects = SshPublicKeyManager() + + class Meta: + verbose_name = _('SSH public key') + verbose_name_plural = _('SSH public keys') + unique_together = [('user', 'algorithm', 'data')] + + def __str__(self): + return "{algorithm} {data} {comment}".format( + algorithm=self.algorithm, data=self.data, comment=self.comment + ).strip() + + def save(self, **kwargs): + key = super(SshPublicKey, self).save(**kwargs) + TaskResult.objects.create_task_result( + set_file_ssh_authorized_keys.delay( + self.user.username, [ + str(key) for key in + SshPublicKey.objects.filter(user=self.user)]), + 'set_file_ssh_authorized_keys' + ) + return key + + def delete(self, **kwargs): + super(SshPublicKey, self).delete(**kwargs) + TaskResult.objects.create_task_result( + set_file_ssh_authorized_keys.delay( + self.user.username, [ + str(key) for key in + SshPublicKey.objects.filter(user=self.user)]), + 'set_file_ssh_authorized_keys' + ) diff --git a/gnuviechadmin/osusers/tests/test_admin.py b/gnuviechadmin/osusers/tests/test_admin.py index 8e49ccb..405116f 100644 --- a/gnuviechadmin/osusers/tests/test_admin.py +++ b/gnuviechadmin/osusers/tests/test_admin.py @@ -3,7 +3,9 @@ from django.contrib.admin import AdminSite from django.test import TestCase from django.test.utils import override_settings -from mock import patch, Mock +from django.contrib.auth import get_user_model + +from mock import Mock from osusers.models import ( Group, @@ -16,31 +18,24 @@ from osusers.admin import ( UserCreationForm, ) +Customer = get_user_model() -class TaskResultInlineTest(TestCase): + +class CustomerTestCase(TestCase): def setUp(self): - self.site = AdminSite() - super(TaskResultInlineTest, self).setUp() - - def test_get_queryset_calls_update_taskstatus(self): - with patch('osusers.admin.admin.TabularInline.get_queryset') as mock: - entrymock = Mock(name='entry') - mock.return_value = [entrymock] - requestmock = Mock(name='request') - UserTaskResultInline(User, self.site).get_queryset(requestmock) - entrymock.update_taskstatus.assert_calledwith() - - def test_has_add_permissions_returns_false(self): - self.assertFalse( - UserTaskResultInline(User, self.site).has_add_permission( - self, Mock(name='request')) - ) + self.customer = Customer.objects.create_user('test') + super(CustomerTestCase, self).setUp() -class UserCreationFormTest(TestCase): +class UserCreationFormTest(CustomerTestCase): + def test_clean_password2_same(self): form = UserCreationForm() - form.cleaned_data = {'password1': 'secret', 'password2': 'secret'} + form.cleaned_data = { + 'customer': self.customer, + 'password1': 'secret', + 'password2': 'secret' + } self.assertEqual(form.clean_password2(), 'secret') def test_clean_password2_empty(self): @@ -50,7 +45,11 @@ class UserCreationFormTest(TestCase): def test_clean_password2_mismatch(self): form = UserCreationForm() - form.cleaned_data = {'password1': 'secretx', 'password2': 'secrety'} + form.cleaned_data = { + 'customer': self.customer, + 'password1': 'secretx', + 'password2': 'secrety' + } with self.assertRaises(forms.ValidationError) as cm: form.clean_password2() self.assertEqual(cm.exception.message, PASSWORD_MISMATCH_ERROR) @@ -62,7 +61,11 @@ class UserCreationFormTest(TestCase): ) def test_save_commit(self): form = UserCreationForm() - form.cleaned_data = {'password1': 'secret', 'password2': 'secret'} + form.cleaned_data = { + 'customer': self.customer, + 'password1': 'secret', + 'password2': 'secret' + } user = form.save() self.assertIsNotNone(user) self.assertEqual(User.objects.get(pk=user.uid), user) @@ -72,7 +75,7 @@ class UserCreationFormTest(TestCase): self.assertIsNone(form.save_m2m()) -class UserAdminTest(TestCase): +class UserAdminTest(CustomerTestCase): def setUp(self): site = AdminSite() self.uadmin = UserAdmin(User, site) @@ -91,11 +94,12 @@ class UserAdminTest(TestCase): BROKER_BACKEND='memory' ) def test_get_form_with_object(self): - user = User.objects.create_user() + user = User.objects.create_user(customer=self.customer) form = self.uadmin.get_form(Mock(name='request'), user) self.assertEqual( form.Meta.fields, - ['username', 'group', 'gecos', 'homedir', 'shell', 'uid'] + ['username', 'group', 'gecos', 'homedir', 'shell', 'customer', + 'uid'] ) def test_get_inline_instances_without_object(self): @@ -108,7 +112,7 @@ class UserAdminTest(TestCase): BROKER_BACKEND='memory' ) def test_get_inline_instances_with_object(self): - user = User.objects.create_user() + user = User.objects.create_user(customer=self.customer) inlines = self.uadmin.get_inline_instances( Mock(name='request'), user) self.assertEqual(len(inlines), len(UserAdmin.inlines)) @@ -138,27 +142,3 @@ class GroupAdminTest(TestCase): self.assertEqual(len(inlines), len(GroupAdmin.inlines)) for index in range(len(inlines)): self.assertIsInstance(inlines[index], GroupAdmin.inlines[index]) - - -class DeleteTaskResultAdminTest(TestCase): - def setUp(self): - site = AdminSite() - self.dtradmin = DeleteTaskResultAdmin(DeleteTaskResult, site) - super(DeleteTaskResultAdminTest, self).setUp() - - def test_has_add_permission_returns_false_without_object(self): - self.assertFalse( - self.dtradmin.has_add_permission(Mock(name='request'))) - - def test_has_add_permission_returns_false_with_object(self): - self.assertFalse( - self.dtradmin.has_add_permission(Mock(name='request'), - Mock(name='test'))) - - def test_get_queryset_calls_update_taskstatus(self): - with patch('osusers.admin.admin.ModelAdmin.get_queryset') as mock: - entrymock = Mock(name='entry') - mock.return_value = [entrymock] - requestmock = Mock(name='request') - self.dtradmin.get_queryset(requestmock) - entrymock.update_taskstatus.assert_calledwith() diff --git a/gnuviechadmin/osusers/tests/test_models.py b/gnuviechadmin/osusers/tests/test_models.py index f1a2c11..1897234 100644 --- a/gnuviechadmin/osusers/tests/test_models.py +++ b/gnuviechadmin/osusers/tests/test_models.py @@ -1,11 +1,11 @@ from datetime import date +from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone - -from mock import patch, MagicMock +from django.contrib.auth import get_user_model from passlib.hash import sha512_crypt @@ -14,8 +14,84 @@ from osusers.models import ( AdditionalGroup, Group, Shadow, + SshPublicKey, User, ) +from taskresults.models import TaskResult + + +EXAMPLE_KEY_1_RFC4716 = """---- BEGIN SSH2 PUBLIC KEY ---- +Comment: "1024-bit RSA, converted from OpenSSH by me@example.com" +x-command: /home/me/bin/lock-in-guest.sh +AAAAB3NzaC1yc2EAAAABIwAAAIEA1on8gxCGJJWSRT4uOrR13mUaUk0hRf4RzxSZ1zRb +YYFw8pfGesIFoEuVth4HKyF8k1y4mRUnYHP1XNMNMJl1JcEArC2asV8sHf6zSPVffozZ +5TT4SfsUu/iKy9lUcCfXzwre4WWZSXXcPff+EHtWshahu3WzBdnGxm5Xoi89zcE= +---- END SSH2 PUBLIC KEY ----""" + +EXAMPLE_KEY_2_RFC4716 = """---- BEGIN SSH2 PUBLIC KEY ---- +Comment: This is my public key for use on \ +servers which I don't like. +AAAAB3NzaC1kc3MAAACBAPY8ZOHY2yFSJA6XYC9HRwNHxaehvx5wOJ0rzZdzoSOXxbET +W6ToHv8D1UJ/z+zHo9Fiko5XybZnDIaBDHtblQ+Yp7StxyltHnXF1YLfKD1G4T6JYrdH +YI14Om1eg9e4NnCRleaqoZPF3UGfZia6bXrGTQf3gJq2e7Yisk/gF+1VAAAAFQDb8D5c +vwHWTZDPfX0D2s9Rd7NBvQAAAIEAlN92+Bb7D4KLYk3IwRbXblwXdkPggA4pfdtW9vGf +J0/RHd+NjB4eo1D+0dix6tXwYGN7PKS5R/FXPNwxHPapcj9uL1Jn2AWQ2dsknf+i/FAA +vioUPkmdMc0zuWoSOEsSNhVDtX3WdvVcGcBq9cetzrtOKWOocJmJ80qadxTRHtUAAACB +AN7CY+KKv1gHpRzFwdQm7HK9bb1LAo2KwaoXnadFgeptNBQeSXG1vO+JsvphVMBJc9HS +n24VYtYtsMu74qXviYjziVucWKjjKEb11juqnF0GDlB3VVmxHLmxnAz643WK42Z7dLM5 +sY29ouezv4Xz2PuMch5VGPP+CDqzCM4loWgV +---- END SSH2 PUBLIC KEY ----""" + +EXAMPLE_KEY_3_RFC4716 = """---- BEGIN SSH2 PUBLIC KEY ---- +Comment: DSA Public Key for use with MyIsp +AAAAB3NzaC1kc3MAAACBAPY8ZOHY2yFSJA6XYC9HRwNHxaehvx5wOJ0rzZdzoSOXxbET +W6ToHv8D1UJ/z+zHo9Fiko5XybZnDIaBDHtblQ+Yp7StxyltHnXF1YLfKD1G4T6JYrdH +YI14Om1eg9e4NnCRleaqoZPF3UGfZia6bXrGTQf3gJq2e7Yisk/gF+1VAAAAFQDb8D5c +vwHWTZDPfX0D2s9Rd7NBvQAAAIEAlN92+Bb7D4KLYk3IwRbXblwXdkPggA4pfdtW9vGf +J0/RHd+NjB4eo1D+0dix6tXwYGN7PKS5R/FXPNwxHPapcj9uL1Jn2AWQ2dsknf+i/FAA +vioUPkmdMc0zuWoSOEsSNhVDtX3WdvVcGcBq9cetzrtOKWOocJmJ80qadxTRHtUAAACB +AN7CY+KKv1gHpRzFwdQm7HK9bb1LAo2KwaoXnadFgeptNBQeSXG1vO+JsvphVMBJc9HS +n24VYtYtsMu74qXviYjziVucWKjjKEb11juqnF0GDlB3VVmxHLmxnAz643WK42Z7dLM5 +sY29ouezv4Xz2PuMch5VGPP+CDqzCM4loWgV +---- END SSH2 PUBLIC KEY ----""" + +EXAMPLE_KEY_4_OPENSSH = "".join(( +"ssh-rsa ", +"AAAAB3NzaC1yc2EAAAABIwAAAIEA1on8gxCGJJWSRT4uOrR13mUaUk0hRf4RzxSZ1zRb", +"YYFw8pfGesIFoEuVth4HKyF8k1y4mRUnYHP1XNMNMJl1JcEArC2asV8sHf6zSPVffozZ", +"5TT4SfsUu/iKy9lUcCfXzwre4WWZSXXcPff+EHtWshahu3WzBdnGxm5Xoi89zcE=" +)) + +EXAMPLE_KEY_5_RFC4716_MULTILINE = """---- BEGIN SSH2 PUBLIC KEY ---- +Comment: DSA Public Key \\ +for use with \\ +MyIsp +AAAAB3NzaC1kc3MAAACBAPY8ZOHY2yFSJA6XYC9HRwNHxaehvx5wOJ0rzZdzoSOXxbET +W6ToHv8D1UJ/z+zHo9Fiko5XybZnDIaBDHtblQ+Yp7StxyltHnXF1YLfKD1G4T6JYrdH +YI14Om1eg9e4NnCRleaqoZPF3UGfZia6bXrGTQf3gJq2e7Yisk/gF+1VAAAAFQDb8D5c +vwHWTZDPfX0D2s9Rd7NBvQAAAIEAlN92+Bb7D4KLYk3IwRbXblwXdkPggA4pfdtW9vGf +J0/RHd+NjB4eo1D+0dix6tXwYGN7PKS5R/FXPNwxHPapcj9uL1Jn2AWQ2dsknf+i/FAA +vioUPkmdMc0zuWoSOEsSNhVDtX3WdvVcGcBq9cetzrtOKWOocJmJ80qadxTRHtUAAACB +AN7CY+KKv1gHpRzFwdQm7HK9bb1LAo2KwaoXnadFgeptNBQeSXG1vO+JsvphVMBJc9HS +n24VYtYtsMu74qXviYjziVucWKjjKEb11juqnF0GDlB3VVmxHLmxnAz643WK42Z7dLM5 +sY29ouezv4Xz2PuMch5VGPP+CDqzCM4loWgV +---- END SSH2 PUBLIC KEY ----""" + +EXAMPLE_KEY_6_RFC4716_EMPTY_LINE = """---- BEGIN SSH2 PUBLIC KEY ---- +Comment: DSA Public Key for use with MyIsp + +AAAAB3NzaC1kc3MAAACBAPY8ZOHY2yFSJA6XYC9HRwNHxaehvx5wOJ0rzZdzoSOXxbET +W6ToHv8D1UJ/z+zHo9Fiko5XybZnDIaBDHtblQ+Yp7StxyltHnXF1YLfKD1G4T6JYrdH +YI14Om1eg9e4NnCRleaqoZPF3UGfZia6bXrGTQf3gJq2e7Yisk/gF+1VAAAAFQDb8D5c +vwHWTZDPfX0D2s9Rd7NBvQAAAIEAlN92+Bb7D4KLYk3IwRbXblwXdkPggA4pfdtW9vGf +J0/RHd+NjB4eo1D+0dix6tXwYGN7PKS5R/FXPNwxHPapcj9uL1Jn2AWQ2dsknf+i/FAA +vioUPkmdMc0zuWoSOEsSNhVDtX3WdvVcGcBq9cetzrtOKWOocJmJ80qadxTRHtUAAACB +AN7CY+KKv1gHpRzFwdQm7HK9bb1LAo2KwaoXnadFgeptNBQeSXG1vO+JsvphVMBJc9HS +n24VYtYtsMu74qXviYjziVucWKjjKEb11juqnF0GDlB3VVmxHLmxnAz643WK42Z7dLM5 +sY29ouezv4Xz2PuMch5VGPP+CDqzCM4loWgV +---- END SSH2 PUBLIC KEY ----""" + +Customer = get_user_model() @override_settings( @@ -29,9 +105,10 @@ class TestCaseWithCeleryTasks(TestCase): class AdditionalGroupTest(TestCaseWithCeleryTasks): def setUp(self): + customer = Customer.objects.create(username='test') self.group1 = Group.objects.create(groupname='test1', gid=1000) self.user = User.objects.create( - username='test', uid=1000, group=self.group1, + customer=customer, username='test', uid=1000, group=self.group1, homedir='/home/test', shell='/bin/bash') def test_clean_primary_group(self): @@ -48,13 +125,11 @@ class AdditionalGroupTest(TestCaseWithCeleryTasks): def test_save(self): group2 = Group.objects.create(groupname='test2', gid=1001) - GroupTaskResult.objects.all().delete() addgroup = AdditionalGroup(user=self.user, group=group2) addgroup.save() - taskres = GroupTaskResult.objects.all() + taskres = TaskResult.objects.all() self.assertTrue(len(taskres), 1) - self.assertEqual(taskres[0].task_name, 'add_ldap_user_to_group') - self.assertEqual(taskres[0].group, group2) + self.assertEqual(taskres[0].task_name, 'setup_file_sftp_userdir') def test_delete(self): group2 = Group.objects.create(groupname='test2', gid=1001) @@ -86,26 +161,27 @@ class GroupTest(TestCaseWithCeleryTasks): def test_save(self): group = Group(gid=10000, groupname='test') self.assertIs(group.save(), group) - taskres = GroupTaskResult.objects.all() - self.assertEqual(len(taskres), 1) - self.assertEqual(taskres[0].group, group) - self.assertEqual(taskres[0].task_name, 'create_ldap_group') def test_delete(self): group = Group.objects.create(gid=10000, groupname='test') self.assertEqual(len(Group.objects.all()), 1) - self.assertEqual(len(GroupTaskResult.objects.all()), 1) group.delete() self.assertEqual(len(Group.objects.all()), 0) - self.assertEqual(len(GroupTaskResult.objects.all()), 0) + self.assertEqual(len(TaskResult.objects.all()), 1) + tr = TaskResult.objects.first() + self.assertEqual(tr.task_name, 'delete_ldap_group') class ShadowManagerTest(TestCaseWithCeleryTasks): + def setUp(self): + self.customer = Customer.objects.create(username='test') + super(ShadowManagerTest, self).setUp() + def test_create_shadow(self): user = User( - username='test', uid=1000, - group=Group(gid=1000, groupname='test'), - homedir='/home/test', shell='/bin/fooshell') + customer=self.customer, username='test', uid=1000, + group=Group(gid=1000, groupname='test'), homedir='/home/test', + shell='/bin/fooshell') shadow = Shadow.objects.create_shadow(user, 'test') self.assertTrue(sha512_crypt.verify('test', shadow.passwd)) self.assertEqual(shadow.changedays, @@ -119,12 +195,16 @@ class ShadowManagerTest(TestCaseWithCeleryTasks): class ShadowTest(TestCaseWithCeleryTasks): + def setUp(self): + self.customer = Customer.objects.create(username='test') + super(ShadowTest, self).setUp() + def test___str__(self): group = Group.objects.create( groupname='test', gid=1000) user = User.objects.create( - username='test', uid=1000, group=group, homedir='/home/test', - shell='/bin/bash') + customer=self.customer, username='test', uid=1000, group=group, + homedir='/home/test', shell='/bin/bash') shadow = Shadow(user=user) self.assertEqual(str(shadow), 'for user test (1000)') @@ -132,90 +212,13 @@ class ShadowTest(TestCaseWithCeleryTasks): group = Group.objects.create( groupname='test', gid=1000) user = User.objects.create( - username='test', uid=1000, group=group, homedir='/home/test', - shell='/bin/bash') + customer=self.customer, username='test', uid=1000, group=group, + homedir='/home/test', shell='/bin/bash') shadow = Shadow(user=user) shadow.set_password('test') self.assertTrue(sha512_crypt.verify('test', shadow.passwd)) -TEST_TASK_UUID = '3120f6a8-2665-4fa3-a785-79efd28bfe92' -TEST_TASK_NAME = 'test.task' -TEST_TASK_RESULT = '4ll y0ur b453 4r3 b3l0ng t0 u5' - - -class TaskResultTest(TestCase): - def test__set_result_fields_not_ready(self): - mock = MagicMock(task_id=TEST_TASK_UUID, task_name=TEST_TASK_NAME) - mock.ready.return_value = False - tr = DeleteTaskResult.objects.create(mock, TEST_TASK_NAME) - self.assertFalse(tr.is_finished) - self.assertFalse(tr.is_success) - self.assertEqual(tr.state, '') - self.assertEqual(tr.result_body, '') - - def test__set_result_fields_ready(self): - mock = MagicMock(task_id=TEST_TASK_UUID, task_name=TEST_TASK_NAME, - state='SUCCESS', result=TEST_TASK_RESULT) - mock.ready.return_value = True - tr = DeleteTaskResult.objects.create(mock, TEST_TASK_NAME) - self.assertTrue(tr.is_finished) - self.assertTrue(tr.is_success) - self.assertEqual(tr.state, 'SUCCESS') - self.assertEqual(tr.result_body, TEST_TASK_RESULT) - - def test__set_result_fields_exception(self): - mock = MagicMock(task_id=TEST_TASK_UUID, task_name=TEST_TASK_NAME, - state='FAILURE', result=Exception('Fail')) - mock.ready.return_value = True - tr = DeleteTaskResult.objects.create(mock, TEST_TASK_NAME) - self.assertTrue(tr.is_finished) - self.assertFalse(tr.is_success) - self.assertEqual(tr.state, 'FAILURE') - self.assertEqual(tr.result_body, 'Fail') - - @patch('osusers.models.AsyncResult') - def test_update_taskstatus_unfinished(self, asyncres): - mock = MagicMock(task_id=TEST_TASK_UUID, task_name=TEST_TASK_NAME) - mock.ready.return_value = False - tr = DeleteTaskResult.objects.create(mock, TEST_TASK_NAME) - self.assertFalse(tr.is_finished) - mymock = asyncres(TEST_TASK_UUID) - mymock.ready.return_value = True - mymock.state = 'SUCCESS' - mymock.result = TEST_RESULT - tr.update_taskstatus() - mymock.ready.assert_called_with() - self.assertTrue(tr.is_finished) - - @patch('osusers.models.AsyncResult') - def test_update_taskstatus_finished(self, asyncres): - mock = MagicMock(task_id=TEST_TASK_UUID, task_name=TEST_TASK_NAME) - mock.ready.return_value = True - mock.state = 'SUCCESS' - mock.result = TEST_RESULT - tr = DeleteTaskResult.objects.create(mock, TEST_TASK_NAME) - self.assertTrue(tr.is_finished) - mymock = asyncres(TEST_TASK_UUID) - tr.update_taskstatus() - self.assertFalse(mymock.ready.called) - self.assertTrue(tr.is_finished) - - -TEST_RESULT = MagicMock() -TEST_RESULT.task_id = TEST_TASK_UUID -TEST_RESULT.task_name = TEST_TASK_NAME -TEST_RESULT.ready.return_value = False - - -class TaskResultManagerTest(TestCase): - def test_create(self): - tr = DeleteTaskResult.objects.create(TEST_RESULT, TEST_TASK_NAME) - self.assertIsInstance(tr, DeleteTaskResult) - self.assertEqual(tr.task_uuid, TEST_TASK_UUID) - self.assertEqual(tr.task_name, TEST_TASK_NAME) - - @override_settings( OSUSER_MINUID=10000, OSUSER_MINGID=10000, OSUSER_USERNAME_PREFIX='test', OSUSER_HOME_BASEPATH='/home', OSUSER_DEFAULT_SHELL='/bin/fooshell' @@ -224,13 +227,18 @@ class UserManagerTest(TestCaseWithCeleryTasks): def _create_group(self): return Group.objects.create(gid=10000, groupname='foo') + def setUp(self): + self.customer = Customer.objects.create(username='test') + super(UserManagerTest, self).setUp() + def test_get_next_uid_first(self): self.assertEqual(User.objects.get_next_uid(), 10000) def test_get_next_uid_second(self): User.objects.create( - uid=10010, username='foo', group=self._create_group(), - homedir='/home/foo', shell='/bin/fooshell') + customer=self.customer, uid=10010, username='foo', + group=self._create_group(), homedir='/home/foo', + shell='/bin/fooshell') self.assertEqual(User.objects.get_next_uid(), 10011) def test_get_next_username_first(self): @@ -238,22 +246,23 @@ class UserManagerTest(TestCaseWithCeleryTasks): def test_get_next_username_second(self): User.objects.create( - uid=10000, username='test01', group=self._create_group(), - homedir='/home/foo', shell='/bin/fooshell') + customer=self.customer, uid=10000, username='test01', + group=self._create_group(), homedir='/home/foo', + shell='/bin/fooshell') self.assertEqual(User.objects.get_next_username(), 'test02') def test_get_next_username_gaps(self): group = self._create_group() User.objects.create( - uid=10000, username='test01', group=group, + customer=self.customer, uid=10000, username='test01', group=group, homedir='/home/foo', shell='/bin/fooshell') User.objects.create( - uid=10002, username='test03', group=group, + customer=self.customer, uid=10002, username='test03', group=group, homedir='/home/foo', shell='/bin/fooshell') self.assertEqual(User.objects.get_next_username(), 'test02') def test_create_user_first(self): - user = User.objects.create_user() + user = User.objects.create_user(customer=self.customer) self.assertIsInstance(user, User) self.assertEqual(user.uid, 10000) self.assertEqual(user.group.gid, 10000) @@ -264,15 +273,16 @@ class UserManagerTest(TestCaseWithCeleryTasks): self.assertIsNotNone(user.shadow) def test_create_user_tasks(self): - user = User.objects.create_user() - gtaskres = GroupTaskResult.objects.all() - self.assertEqual(len(gtaskres), 1) - self.assertEqual(gtaskres[0].task_name, 'create_ldap_group') - self.assertEqual(gtaskres[0].group, user.group) + User.objects.create_user(customer=self.customer) + taskres = TaskResult.objects.all() + self.assertEqual(len(taskres), 2) + tasknames = [r.task_name for r in taskres] + self.assertEqual(tasknames.count('setup_file_sftp_userdir'), 1) + self.assertEqual(tasknames.count('setup_file_mail_userdir'), 1) def test_create_user_second(self): - User.objects.create_user() - user = User.objects.create_user() + User.objects.create_user(customer=self.customer) + user = User.objects.create_user(customer=self.customer) self.assertIsInstance(user, User) self.assertEqual(user.uid, 10001) self.assertEqual(user.group.gid, 10001) @@ -284,7 +294,8 @@ class UserManagerTest(TestCaseWithCeleryTasks): self.assertEqual(len(User.objects.all()), 2) def test_create_user_known_password(self): - user = User.objects.create_user(password='foobar') + user = User.objects.create_user( + customer=self.customer, password='foobar') self.assertIsInstance(user, User) self.assertEqual(user.uid, 10000) self.assertEqual(user.group.gid, 10000) @@ -296,7 +307,8 @@ class UserManagerTest(TestCaseWithCeleryTasks): self.assertTrue(sha512_crypt.verify('foobar', user.shadow.passwd)) def test_create_user_predefined_username(self): - user = User.objects.create_user(username='tester') + user = User.objects.create_user( + customer=self.customer, username='tester') self.assertIsInstance(user, User) self.assertEqual(user.uid, 10000) self.assertEqual(user.group.gid, 10000) @@ -307,7 +319,7 @@ class UserManagerTest(TestCaseWithCeleryTasks): self.assertIsNotNone(user.shadow) def test_create_user_commit(self): - user = User.objects.create_user(commit=True) + user = User.objects.create_user(customer=self.customer, commit=True) self.assertIsInstance(user, User) self.assertEqual(user.uid, 10000) self.assertEqual(user.group.gid, 10000) @@ -323,55 +335,176 @@ class UserManagerTest(TestCaseWithCeleryTasks): OSUSER_HOME_BASEPATH='/home', OSUSER_DEFAULT_SHELL='/bin/fooshell' ) class UserTest(TestCaseWithCeleryTasks): + def setUp(self): + self.customer = Customer.objects.create_user('test') + super(UserTest, self).setUp() def test___str__(self): - user = User.objects.create_user() + user = User.objects.create_user(self.customer) self.assertEqual(str(user), 'test01 (10000)') def test_set_password(self): - user = User.objects.create_user() + user = User.objects.create_user(self.customer) self.assertFalse(sha512_crypt.verify('test', user.shadow.passwd)) - UserTaskResult.objects.all().delete() user.set_password('test') self.assertTrue(sha512_crypt.verify('test', user.shadow.passwd)) - taskres = UserTaskResult.objects.all() - self.assertEqual(len(taskres), 1) - self.assertEqual(taskres[0].user, user) - self.assertEqual(taskres[0].task_name, 'create_ldap_user') def test_save(self): - user = User.objects.create_user() - UserTaskResult.objects.all().delete() + user = User.objects.create_user(self.customer) + TaskResult.objects.all().delete() user.save() - taskres = UserTaskResult.objects.all() - self.assertEqual(len(taskres), 1) - self.assertEqual(taskres[0].user, user) - self.assertEqual(taskres[0].task_name, 'create_ldap_user') + taskres = TaskResult.objects.all() + self.assertEqual(len(taskres), 2) + task_names = [r.task_name for r in taskres] + self.assertIn('setup_file_sftp_userdir', task_names) + self.assertIn('setup_file_mail_userdir', task_names) def test_delete_only_user(self): - user = User.objects.create_user() + user = User.objects.create_user(self.customer) + TaskResult.objects.all().delete() user.delete() - taskres = DeleteTaskResult.objects.all() - self.assertEqual(len(taskres), 2) - self.assertIn('delete_ldap_user', - [r.task_name for r in taskres]) - self.assertIn('delete_ldap_group_if_empty', - [r.task_name for r in taskres]) + taskres = TaskResult.objects.all() + self.assertEqual(len(taskres), 3) + tasknames = [r.task_name for r in taskres] + self.assertEqual(tasknames.count('delete_file_mail_userdir'), 1) + self.assertEqual(tasknames.count('delete_file_sftp_userdir'), 1) + self.assertEqual(tasknames.count('delete_ldap_group'), 1) self.assertEqual(len(User.objects.all()), 0) def test_delete_additional_groups(self): group1 = Group.objects.create(gid=2000, groupname='group1') group2 = Group.objects.create(gid=2001, groupname='group2') - user = User.objects.create_user() + user = User.objects.create_user(self.customer) for group in [group1, group2]: user.additionalgroup_set.add( AdditionalGroup.objects.create(user=user, group=group)) + TaskResult.objects.all().delete() user.delete() - taskres = DeleteTaskResult.objects.all() - self.assertEqual(len(taskres), 4) + taskres = TaskResult.objects.all() + self.assertEqual(len(taskres), 3) tasknames = [t.task_name for t in taskres] - self.assertEqual(tasknames.count('remove_ldap_user_from_group'), 2) - self.assertEqual(tasknames.count('delete_ldap_user'), 1) - self.assertEqual(tasknames.count('delete_ldap_group_if_empty'), 1) + self.assertEqual(tasknames.count('delete_file_mail_userdir'), 1) + self.assertEqual(tasknames.count('delete_file_sftp_userdir'), 1) + self.assertEqual(tasknames.count('delete_ldap_group'), 1) self.assertEqual(len(User.objects.all()), 0) self.assertEqual(len(AdditionalGroup.objects.all()), 0) + + def test_is_sftp_user(self): + user = User.objects.create_user(self.customer) + self.assertFalse(user.is_sftp_user()) + + sftp_group = Group.objects.create( + gid=2000, groupname=settings.OSUSER_SFTP_GROUP) + user.additionalgroup_set.add( + AdditionalGroup.objects.create(user=user, group=sftp_group)) + self.assertTrue(user.is_sftp_user()) + + +class SshPublicKeyManagerTest(TestCaseWithCeleryTasks): + def test_parse_keytext_rfc4716_1(self): + res = SshPublicKey.objects.parse_keytext(EXAMPLE_KEY_1_RFC4716) + self.assertEqual(len(res), 3) + self.assertGreater(len(res[1]), 40) + self.assertEqual(res[0], 'ssh-rsa') + self.assertEqual( + res[2], '"1024-bit RSA, converted from OpenSSH by me@example.com"') + + def test_parse_keytext_rfc4716_2(self): + res = SshPublicKey.objects.parse_keytext(EXAMPLE_KEY_2_RFC4716) + self.assertEqual(len(res), 3) + self.assertEqual(res[0], 'ssh-dss') + self.assertGreater(len(res[1]), 40) + self.assertEqual( + res[2], + "This is my public key for use on servers which I don't like.") + + def test_parse_keytext_rfc4716_3(self): + res = SshPublicKey.objects.parse_keytext(EXAMPLE_KEY_3_RFC4716) + self.assertEqual(len(res), 3) + self.assertEqual(res[0], 'ssh-dss') + self.assertGreater(len(res[1]), 40) + self.assertEqual(res[2], "DSA Public Key for use with MyIsp") + + def test_parse_keytext_openssh(self): + res = SshPublicKey.objects.parse_keytext(EXAMPLE_KEY_4_OPENSSH) + self.assertEquals(len(res), 3) + self.assertEqual(res[0], 'ssh-rsa') + self.assertGreater(len(res[1]), 40) + self.assertEqual(res[2], '') + + def test_parse_keytext_invalid(self): + with self.assertRaises(ValueError): + SshPublicKey.objects.parse_keytext("\r\n".join(["xx"]*10)) + + def test_parse_keytext_empty_line(self): + res = SshPublicKey.objects.parse_keytext( + EXAMPLE_KEY_6_RFC4716_EMPTY_LINE) + self.assertEqual(len(res), 3) + self.assertEqual(res[0], 'ssh-dss') + self.assertGreater(len(res[1]), 40) + self.assertEqual(res[2], "DSA Public Key for use with MyIsp") + + def test_parse_keytext_multiline_comment(self): + res = SshPublicKey.objects.parse_keytext( + EXAMPLE_KEY_5_RFC4716_MULTILINE) + self.assertEqual(len(res), 3) + self.assertEqual(res[0], 'ssh-dss') + self.assertGreater(len(res[1]), 40) + self.assertEqual(res[2], "DSA Public Key for use with MyIsp") + + def test_create_ssh_public_key(self): + customer = Customer.objects.create_user('test') + user = User.objects.create_user(customer) + key = SshPublicKey.objects.create_ssh_public_key( + user, EXAMPLE_KEY_4_OPENSSH) + self.assertIsInstance(key, SshPublicKey) + self.assertEqual(key.user, user) + self.assertEqual(key.algorithm, 'ssh-rsa') + self.assertEqual(key.data, EXAMPLE_KEY_4_OPENSSH.split()[1]) + self.assertEqual(key.comment, '') + + +class SshPublicKeyTest(TestCaseWithCeleryTasks): + def setUp(self): + super(SshPublicKeyTest, self).setUp() + customer = Customer.objects.create_user('test') + self.user = User.objects.create_user(customer) + TaskResult.objects.all().delete() + + def test__str__rfc4716(self): + res = SshPublicKey.objects.create_ssh_public_key( + self.user, EXAMPLE_KEY_3_RFC4716) + self.assertEqual( + str(res), 'ssh-dss AAAAB3NzaC1kc3MAAACBAPY8ZOHY2yFSJA6XYC9HRwNHxae' + 'hvx5wOJ0rzZdzoSOXxbETW6ToHv8D1UJ/z+zHo9Fiko5XybZnDIaBDHtblQ+Yp7St' + 'xyltHnXF1YLfKD1G4T6JYrdHYI14Om1eg9e4NnCRleaqoZPF3UGfZia6bXrGTQf3g' + 'Jq2e7Yisk/gF+1VAAAAFQDb8D5cvwHWTZDPfX0D2s9Rd7NBvQAAAIEAlN92+Bb7D4' + 'KLYk3IwRbXblwXdkPggA4pfdtW9vGfJ0/RHd+NjB4eo1D+0dix6tXwYGN7PKS5R/F' + 'XPNwxHPapcj9uL1Jn2AWQ2dsknf+i/FAAvioUPkmdMc0zuWoSOEsSNhVDtX3WdvVc' + 'GcBq9cetzrtOKWOocJmJ80qadxTRHtUAAACBAN7CY+KKv1gHpRzFwdQm7HK9bb1LA' + 'o2KwaoXnadFgeptNBQeSXG1vO+JsvphVMBJc9HSn24VYtYtsMu74qXviYjziVucWK' + 'jjKEb11juqnF0GDlB3VVmxHLmxnAz643WK42Z7dLM5sY29ouezv4Xz2PuMch5VGPP' + '+CDqzCM4loWgV DSA Public Key for use with MyIsp') + + def test__str__openssh(self): + res = SshPublicKey.objects.create_ssh_public_key( + self.user, EXAMPLE_KEY_4_OPENSSH) + self.assertEqual(str(res), EXAMPLE_KEY_4_OPENSSH) + + def test_call_tasks_on_save(self): + SshPublicKey.objects.create_ssh_public_key( + self.user, EXAMPLE_KEY_4_OPENSSH) + taskresults = TaskResult.objects.all() + self.assertEqual(len(taskresults), 1) + self.assertEqual( + taskresults[0].task_name, 'set_file_ssh_authorized_keys') + + def test_call_tasks_on_delete(self): + key = SshPublicKey.objects.create_ssh_public_key( + self.user, EXAMPLE_KEY_4_OPENSSH) + TaskResult.objects.all().delete() + key.delete() + taskresults = TaskResult.objects.all() + self.assertEqual(len(taskresults), 1) + self.assertEqual( + taskresults[0].task_name, 'set_file_ssh_authorized_keys') diff --git a/gnuviechadmin/osusers/urls.py b/gnuviechadmin/osusers/urls.py index 7b62056..52c2657 100644 --- a/gnuviechadmin/osusers/urls.py +++ b/gnuviechadmin/osusers/urls.py @@ -6,11 +6,25 @@ from __future__ import absolute_import, unicode_literals from django.conf.urls import patterns, url -from .views import SetOsUserPassword +from .views import ( + AddSshPublicKey, + DeleteSshPublicKey, + EditSshPublicKeyComment, + ListSshPublicKeys, + SetOsUserPassword, +) urlpatterns = patterns( '', url(r'^(?P[\w0-9@.+-_]+)/setpassword$', SetOsUserPassword.as_view(), name='set_osuser_password'), + url(r'^(?P\d+)/ssh-keys/$', ListSshPublicKeys.as_view(), + name='list_ssh_keys'), + url(r'^(?P\d+)/ssh-keys/add$', AddSshPublicKey.as_view(), + name='add_ssh_key'), + url(r'^(?P\d+)/ssh-keys/(?P\d+)/edit-comment$', + EditSshPublicKeyComment.as_view(), name='edit_ssh_key_comment'), + url(r'^(?P\d+)/ssh-keys/(?P\d+)/delete$', + DeleteSshPublicKey.as_view(), name='delete_ssh_key'), ) diff --git a/gnuviechadmin/osusers/views.py b/gnuviechadmin/osusers/views.py index a78067a..0f74cf7 100644 --- a/gnuviechadmin/osusers/views.py +++ b/gnuviechadmin/osusers/views.py @@ -4,15 +4,29 @@ This module defines the views for gnuviechadmin operating system user handling. """ from __future__ import unicode_literals, absolute_import +from django.core.urlresolvers import reverse from django.shortcuts import redirect -from django.views.generic import UpdateView +from django.views.generic import ( + CreateView, + DeleteView, + ListView, + UpdateView, +) from django.utils.translation import ugettext as _ from django.contrib import messages from gvacommon.viewmixins import StaffOrSelfLoginRequiredMixin +from gvawebcore.views import HostingPackageAndCustomerMixin -from .forms import ChangeOsUserPasswordForm -from .models import User +from .forms import ( + AddSshPublicKeyForm, + ChangeOsUserPasswordForm, + EditSshPublicKeyCommentForm, +) +from .models import ( + SshPublicKey, + User, +) class SetOsUserPassword(StaffOrSelfLoginRequiredMixin, UpdateView): @@ -43,3 +57,131 @@ class SetOsUserPassword(StaffOrSelfLoginRequiredMixin, UpdateView): username=osuser.username )) return redirect(osuser.customerhostingpackage) + + +class AddSshPublicKey( + HostingPackageAndCustomerMixin, StaffOrSelfLoginRequiredMixin, CreateView +): + """ + This view is used to add an SSH key for an existing hosting account's + operating system user. + + """ + model = SshPublicKey + context_object_name = 'key' + template_name_suffix = '_create' + form_class = AddSshPublicKeyForm + + def get_form_kwargs(self): + kwargs = super(AddSshPublicKey, self).get_form_kwargs() + kwargs['hostingpackage'] = self.get_hosting_package() + return kwargs + + def get_context_data(self, **kwargs): + context = super(AddSshPublicKey, self).get_context_data(**kwargs) + context.update({ + 'customer': self.get_customer_object(), + 'osuser': self.get_hosting_package().osuser.username, + }) + return context + + def form_valid(self, form): + key = form.save() + messages.success( + self.request, + _('Successfully added new {algorithm} SSH public key').format( + algorithm=key.algorithm) + ) + return redirect(self.get_hosting_package()) + + +class ListSshPublicKeys( + HostingPackageAndCustomerMixin, StaffOrSelfLoginRequiredMixin, ListView +): + """ + This view is used for showing the list of :py:class:`SSH public keys + ` assigned to the hosting package specified + via URL parameter 'pattern'. + + """ + model = SshPublicKey + context_object_name = 'keys' + + def get_context_data(self, **kwargs): + context = super(ListSshPublicKeys, self).get_context_data(**kwargs) + context.update({ + 'hostingpackage': self.get_hosting_package(), + 'customer': self.get_customer_object(), + 'osuser': self.get_hosting_package().osuser.username, + }) + return context + + +class DeleteSshPublicKey( + HostingPackageAndCustomerMixin, StaffOrSelfLoginRequiredMixin, DeleteView +): + """ + This view is used for delete confirmation of a :py:class:`SSH public key + `. + + """ + + model = SshPublicKey + context_object_name = 'key' + + def get_queryset(self): + return super(DeleteSshPublicKey, self).get_queryset().filter( + user=self.get_hosting_package().osuser) + + def get_context_data(self, **kwargs): + context = super(DeleteSshPublicKey, self).get_context_data(**kwargs) + context.update({ + 'hostingpackage': self.get_hosting_package(), + 'customer': self.get_customer_object(), + 'osuser': self.get_hosting_package().osuser.username, + }) + return context + + def get_success_url(self): + return reverse( + 'list_ssh_keys', kwargs={'package': self.get_hosting_package().id} + ) + + +class EditSshPublicKeyComment( + HostingPackageAndCustomerMixin, StaffOrSelfLoginRequiredMixin, UpdateView +): + """ + This view is used for editing the comment field of a :py:class:`SSH public + key `. + + """ + model = SshPublicKey + context_object_name = 'key' + fields = ['comment'] + template_name_suffix = '_edit_comment' + form_class = EditSshPublicKeyCommentForm + + def get_queryset(self): + return super(EditSshPublicKeyComment, self).get_queryset().filter( + user=self.get_hosting_package().osuser) + + def get_form_kwargs(self): + kwargs = super(EditSshPublicKeyComment, self).get_form_kwargs() + kwargs['hostingpackage'] = self.get_hosting_package() + return kwargs + + def get_context_data(self, **kwargs): + context = super(EditSshPublicKeyComment, self).get_context_data( + **kwargs) + context.update({ + 'hostingpackage': self.get_hosting_package(), + 'customer': self.get_customer_object(), + 'osuser': self.get_hosting_package().osuser.username, + }) + return context + + def get_success_url(self): + return reverse( + 'list_ssh_keys', kwargs={'package': self.get_hosting_package().id} + ) diff --git a/gnuviechadmin/taskresults/tests/__init__.py b/gnuviechadmin/taskresults/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gnuviechadmin/taskresults/tests/test_models.py b/gnuviechadmin/taskresults/tests/test_models.py new file mode 100644 index 0000000..dcace59 --- /dev/null +++ b/gnuviechadmin/taskresults/tests/test_models.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import, unicode_literals +from django.test import TestCase + +from mock import patch, MagicMock +from taskresults.models import TaskResult + + +TEST_TASK_UUID = '3120f6a8-2665-4fa3-a785-79efd28bfe92' +TEST_TASK_NAME = 'test.task' +TEST_TASK_RESULT = '4ll y0ur b453 4r3 b3l0ng t0 u5' + + +class TaskResultTest(TestCase): + @patch('taskresults.models.app') + def test_update_taskstatus_unfinished(self, app): + mock = MagicMock(id=TEST_TASK_UUID, task_name=TEST_TASK_NAME) + mock.ready.return_value = False + tr = TaskResult.objects.create_task_result(mock, TEST_TASK_NAME) + self.assertFalse(tr.finished) + mymock = app.AsyncResult(TEST_TASK_UUID) + mymock.state = 'SUCCESS' + mymock.get.return_value = TEST_RESULT + tr.fetch_result() + mymock.get.assert_called_with(no_ack=True, timeout=1) + self.assertTrue(tr.finished) + + @patch('taskresults.models.app') + def test_update_taskstatus_finished(self, app): + mock = MagicMock(id=TEST_TASK_UUID, task_name=TEST_TASK_NAME) + mock.ready.return_value = True + mock.state = 'SUCCESS' + mock.result = TEST_RESULT + tr = TaskResult.objects.create_task_result(mock, TEST_TASK_NAME) + tr.fetch_result() + self.assertTrue(tr.finished) + mymock = app.AsyncResult(TEST_TASK_UUID) + tr.fetch_result() + self.assertEqual(mymock.get.call_count, 1) + self.assertTrue(tr.finished) + + +TEST_RESULT = MagicMock() +TEST_RESULT.task_id = TEST_TASK_UUID +TEST_RESULT.task_name = TEST_TASK_NAME +TEST_RESULT.ready.return_value = False + + +class TaskResultManagerTest(TestCase): + def test_create_task_result(self): + mock = MagicMock(id=TEST_TASK_UUID) + tr = TaskResult.objects.create_task_result(mock, TEST_TASK_NAME) + self.assertIsInstance(tr, TaskResult) + self.assertEqual(tr.task_id, TEST_TASK_UUID) + self.assertEqual(tr.task_name, TEST_TASK_NAME) diff --git a/gnuviechadmin/templates/base.html b/gnuviechadmin/templates/base.html index f32143c..8d27d3f 100644 --- a/gnuviechadmin/templates/base.html +++ b/gnuviechadmin/templates/base.html @@ -82,11 +82,12 @@

{% block page_title %}Example Base Template{% endblock page_title %}

{% if messages %} -
    - {% for message in messages %} - {{ message }} - {% endfor %} -
+ {% for message in messages %} + + {% endfor %} {% endif %} {% block content %} diff --git a/gnuviechadmin/templates/hostingpackages/customerhostingpackage_detail.html b/gnuviechadmin/templates/hostingpackages/customerhostingpackage_detail.html index e66ae9b..0818016 100644 --- a/gnuviechadmin/templates/hostingpackages/customerhostingpackage_detail.html +++ b/gnuviechadmin/templates/hostingpackages/customerhostingpackage_detail.html @@ -39,7 +39,7 @@
{% blocktrans with num=hostingpackage.used_mailbox_count total=hostingpackage.mailbox_count %}{{ num }} of {{ total }} in use{% endblocktrans %}
{% if osuser.is_sftp_user %}{% trans "SFTP username" %}{% else %}{% trans "SSH/SFTP username" %}{% endif %}
-
{{ osuser.username }}
+
{{ osuser.username }}{% if sshkeys %} {{ sshkeys|length }}{% endif %}
{% trans "Upload server" %}
{{ uploadserver }}
@@ -68,6 +68,7 @@ diff --git a/gnuviechadmin/templates/osusers/sshpublickey_confirm_delete.html b/gnuviechadmin/templates/osusers/sshpublickey_confirm_delete.html new file mode 100644 index 0000000..022346b --- /dev/null +++ b/gnuviechadmin/templates/osusers/sshpublickey_confirm_delete.html @@ -0,0 +1,35 @@ +{% extends "osusers/base.html" %} +{% load i18n crispy_forms_tags %} + +{% block title %}{{ block.super }} - {% spaceless %} +{% if user == customer %} +{% blocktrans %}Delete SSH Public Key for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}Delete SSH Public Key for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock title %} + +{% block page_title %}{% spaceless %} +{% if user == customer %} +{% blocktrans %}Delete SSH Public Key for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}Delete SSH Public Key for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock page_title %} + +{% block content %} +
+
+ {% blocktrans with algorithm=key.algorithm %}Do you really want to delete the {{ algorithm }} SSH public key?{% endblocktrans %} +
+
+

{% blocktrans %}When you confirm the deletion of this key you will no longer be able to use the corresponding private key for authentication.{% endblocktrans %}

+
{{ key }}
+
+ {% csrf_token %} + + {% trans "Cancel" %} +
+
+
+{% endblock content %} diff --git a/gnuviechadmin/templates/osusers/sshpublickey_create.html b/gnuviechadmin/templates/osusers/sshpublickey_create.html new file mode 100644 index 0000000..bfda9df --- /dev/null +++ b/gnuviechadmin/templates/osusers/sshpublickey_create.html @@ -0,0 +1,30 @@ +{% extends "osusers/base.html" %} +{% load i18n crispy_forms_tags %} + +{% block title %}{{ block.super }} - {% spaceless %} +{% if user == customer %} +{% blocktrans %}Add new SSH Public Key for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}Add a new SSH Public Key for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock title %} + +{% block page_title %}{% spaceless %} +{% if user == customer %} +{% blocktrans %}Add new SSH Public Key for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}Add a new SSH Public Key for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock page_title %} + +{% block content %} +{% crispy form %} +{% endblock content %} + +{% block extra_js %} + +{% endblock extra_js %} diff --git a/gnuviechadmin/templates/osusers/sshpublickey_edit_comment.html b/gnuviechadmin/templates/osusers/sshpublickey_edit_comment.html new file mode 100644 index 0000000..dcdac74 --- /dev/null +++ b/gnuviechadmin/templates/osusers/sshpublickey_edit_comment.html @@ -0,0 +1,30 @@ +{% extends "osusers/base.html" %} +{% load i18n crispy_forms_tags %} + +{% block title %}{{ block.super }} - {% spaceless %} +{% if user == customer %} +{% blocktrans %}Edit Comment of SSH Public Key for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}Edit Comment of SSH Public Key for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock title %} + +{% block page_title %}{% spaceless %} +{% if user == customer %} +{% blocktrans %}Edit Comment of Public Key for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}Edit Comment of SSH Public Key for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock page_title %} + +{% block content %} +{% crispy form %} +{% endblock content %} + +{% block extra_js %} + +{% endblock extra_js %} diff --git a/gnuviechadmin/templates/osusers/sshpublickey_list.html b/gnuviechadmin/templates/osusers/sshpublickey_list.html new file mode 100644 index 0000000..e94d47b --- /dev/null +++ b/gnuviechadmin/templates/osusers/sshpublickey_list.html @@ -0,0 +1,47 @@ +{% extends "osusers/base.html" %} +{% load i18n crispy_forms_tags %} + +{% block title %}{{ block.super }} - {% spaceless %} +{% if user == customer %} +{% blocktrans %}SSH Public Keys for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}SSH Public Keys for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock title %} + +{% block page_title %}{% spaceless %} +{% if user == customer %} +{% blocktrans %}SSH Public Keys for Operating System User {{ osuser }}{% endblocktrans %} +{% else %} +{% blocktrans with full_name=customer.get_full_name %}SSH Public Keys for Operating System User {{ osuser }} of Customer {{ full_name }}{% endblocktrans %} +{% endif %} +{% endspaceless %}{% endblock page_title %} + +{% block content %} +{% if keys %} + + + + + + + + + + {% for key in keys %} + + + + + + {% endfor %} + +
{% trans "Algorithm" %}{% trans "Comment" %}{% trans "Actions" %}
{{ key.algorithm }}{{ key.comment }} + {% trans "Delete" %} + {% trans "Edit Comment" %} +
+{% else %} +

{% trans "There are now SSH public keys set for this operating system user yet." %}

+{% endif %} +

{% trans "Add SSH public key" %}

+{% endblock content %} diff --git a/requirements/base.txt b/requirements/base.txt index 4755e97..74be894 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ -Django==1.7.3 +Django==1.7.4 bpython==0.13.1 django-braces==1.4.0 django-model-utils==2.2