diff --git a/.gitignore b/.gitignore index f25647b..ca72d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ Desktop.ini htmlcov/ tags _build/ +*.mo diff --git a/docs/changelog.rst b/docs/changelog.rst index ff8024b..411c535 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,19 @@ Changelog ========= +* :release:`0.4.0 <2015-01-11>` +* :feature:`-` add mysqltasks and pgsqltasks +* :feature:`-` add :py:mod:`userdbs` app to allow management of user databases + via :py:mod:`mysqltasks` and :py:mod:`pgsqltasks` +* :feature:`-` add new app :py:mod:`taskresults` that takes care of handling + asynchronous `Celery`_ results +* :feature:`-` add new task :py:func:`osusers.tasks.delete_ldap_group` (needs + gvaldap >= 0.2.0 on the LDAP side) +* :feature:`-` add a `customer` field to :py:class:`osusers.models.User` +* :feature:`-` allow empty password input in + :py:class:`osusers.admin.UserCreationForm` to allow generated passwords for + new users + * :release:`0.3.0 <2014-12-27>` * :feature:`-` call create/delete mailbox tasks when saving/deleting mailboxes * :support:`-` use celery routers from gvacommon @@ -23,10 +36,11 @@ Changelog * :feature:`-` full test suite for osusers * :feature:`-` full test suite for managemails app * :feature:`-` full test suite for domains app -* :feature:`-` `Celery `_ integration for ldap - synchronization +* :feature:`-` `Celery`_ integration for ldap synchronization * :release:`0.1 <2014-05-25>` * :feature:`-` initial model code for os users * :feature:`-` initial model code for mail address and mailbox management * :feature:`-` initial model code for domains + +.. _Celery: http://www.celeryproject.org/ diff --git a/docs/code.rst b/docs/code.rst new file mode 100644 index 0000000..8cc0a03 --- /dev/null +++ b/docs/code.rst @@ -0,0 +1,235 @@ +****************** +Code documentation +****************** + +.. index:: Django + +gva is implemented as `Django`_ project and provides a frontend for +administrators and customers. + +.. _Django: https://www.djangoproject.com/ + + +The project module :py:mod:`gnuviechadmin` +========================================== + +.. automodule:: gnuviechadmin + + +:py:mod:`celery ` +--------------------------------------- + +.. automodule:: gnuviechadmin.celery + :members: + + +:py:mod:`urls ` +----------------------------------- + +.. automodule:: gnuviechadmin.urls + + +:py:mod:`wsgi ` +----------------------------------- + +.. automodule:: gnuviechadmin.wsgi + :members: + + +:py:mod:`settings ` +------------------------------------------- + +.. automodule:: gnuviechadmin.settings + + +:py:mod:`base ` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: gnuviechadmin.settings.base + :members: + + +:py:mod:`local ` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: gnuviechadmin.settings.local + + +:py:mod:`production ` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: gnuviechadmin.settings.production + + +:py:mod:`test ` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: gnuviechadmin.settings.test + + +:py:mod:`gvacommon` +=================== + +This module is imported from a separate git project via git subtree and +provides some functionality that is common to all gnuviechadmin subprojects. + +.. automodule:: gvacommon + + +:py:mod:`celeryrouters ` +------------------------------------------------- + +.. automodule:: gvacommon.celeryrouters + :members: + :undoc-members: + + +:py:mod:`managemails` app +========================= + +.. automodule:: managemails + + +:py:mod:`admin ` +----------------------------------- + +.. automodule:: managemails.admin + :members: + + +:py:mod:`models ` +------------------------------------- + +.. automodule:: managemails.models + :members: + + +:py:mod:`mysqltasks` app +======================== + +.. automodule:: mysqltasks + + +:py:mod:`tasks ` +---------------------------------- + +.. automodule:: mysqltasks.tasks + :members: + +.. autotask:: mysqltasks.tasks.create_mysql_database +.. autotask:: mysqltasks.tasks.create_mysql_user +.. autotask:: mysqltasks.tasks.delete_mysql_database +.. autotask:: mysqltasks.tasks.delete_mysql_user +.. autotask:: mysqltasks.tasks.set_mysql_userpassword + + +:py:mod:`osusers` app +===================== + +.. automodule:: osusers + + +:py:mod:`admin ` +------------------------------- + +.. automodule:: osusers.admin + :members: + + +:py:mod:`apps ` +----------------------------- + +.. automodule:: osusers.apps + :members: + + +:py:mod:`models ` +--------------------------------- + +.. automodule:: osusers.models + :members: + + +:py:mod:`tasks ` +------------------------------- + +.. automodule:: osusers.tasks + +.. autotask:: osusers.tasks.add_ldap_user_to_group +.. autotask:: osusers.tasks.create_file_mailbox +.. autotask:: osusers.tasks.create_ldap_group +.. autotask:: osusers.tasks.create_ldap_user +.. autotask:: osusers.tasks.delete_file_mail_userdir +.. autotask:: osusers.tasks.delete_file_mailbox +.. autotask:: osusers.tasks.delete_file_sftp_userdir +.. autotask:: osusers.tasks.delete_ldap_group +.. autotask:: osusers.tasks.delete_ldap_group_if_empty +.. autotask:: osusers.tasks.delete_ldap_user +.. autotask:: osusers.tasks.remove_ldap_user_from_group +.. autotask:: osusers.tasks.setup_file_mail_userdir +.. autotask:: osusers.tasks.setup_file_sftp_userdir + + +:py:mod:`pgsqltasks` app +======================== + +.. automodule:: pgsqltasks + :members: + + +:py:mod:`tasks ` +---------------------------------- + +.. automodule:: pgsqltasks.tasks + +.. autotask:: pgsqltasks.tasks.create_pgsql_database +.. autotask:: pgsqltasks.tasks.create_pgsql_user +.. autotask:: pgsqltasks.tasks.delete_pgsql_database +.. autotask:: pgsqltasks.tasks.delete_pgsql_user +.. autotask:: pgsqltasks.tasks.set_pgsql_userpassword + + +:py:mod:`taskresults` app +========================= + +.. automodule:: taskresults + +:py:mod:`admin ` +----------------------------------- + +.. automodule:: taskresults.admin + +:py:mod:`management.commands ` +--------------------------------------------------------------- + +.. automodule:: taskresults.management.commands + +:py:mod:`fetch_taskresults ` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: taskresults.management.commands.fetch_taskresults + +:py:mod:`models ` +------------------------------------- + +.. automodule:: taskresults.models + + +:py:mod:`userdbs` app +===================== + +.. automodule:: userdbs + + +:py:mod:`admin ` +------------------------------- + +.. automodule:: userdbs.admin + :members: + + +:py:mod:`models ` +--------------------------------- + +.. automodule:: userdbs.models + :members: diff --git a/docs/conf.py b/docs/conf.py index ffdf565..998aeeb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,13 +13,18 @@ # All configuration values have a default; values that are commented out # serve to show the default. -#import sys -#import os +import sys +import os +import django # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath(os.path.join('..', 'gnuviechadmin'))) + +os.environ['GVA_SITE_ADMINMAIL'] = 'admin@gva.example.org' + +django.setup() # -- General configuration ----------------------------------------------------- @@ -48,16 +53,16 @@ master_doc = 'index' # General information about the project. project = u'gnuviechadmin' -copyright = u'2014, Jan Dittberner' +copyright = u'2014, 2015 Jan Dittberner' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.3' +version = '0.4' # The full version, including alpha/beta/rc tags. -release = '0.3.0' +release = '0.4.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/index.rst b/docs/index.rst index 0d9e0c2..3f1d47e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: install deploy tests + code changelog diff --git a/gnuviechadmin/gnuviechadmin/settings/base.py b/gnuviechadmin/gnuviechadmin/settings/base.py index f05a821..33f5b85 100644 --- a/gnuviechadmin/gnuviechadmin/settings/base.py +++ b/gnuviechadmin/gnuviechadmin/settings/base.py @@ -82,7 +82,7 @@ DATABASES = { TIME_ZONE = 'Europe/Berlin' # See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code -LANGUAGE_CODE = 'de-de' +LANGUAGE_CODE = 'en-us' # See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 @@ -224,9 +224,13 @@ DJANGO_APPS = ( # Apps specific for this project go here. LOCAL_APPS = ( + 'taskresults', + 'mysqltasks', + 'pgsqltasks', 'domains', 'osusers', 'managemails', + 'userdbs', ) # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -279,10 +283,11 @@ CELERY_RESULT_BACKEND = 'amqp' CELERY_RESULT_PERSISTENT = True CELERY_TASK_RESULT_EXPIRES = None CELERY_ROUTES = ( - 'gvacommon.celeryrouters.LdapRouter', - 'gvacommon.celeryrouters.FileRouter', + 'gvacommon.celeryrouters.GvaRouter', ) -CELERY_ACCEPT_CONTENT = ['pickle', 'yaml', 'json'] +CELERY_TIMEZONE = 'Europe/Berlin' +CELERY_ENABLE_UTC = True +CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' ########## END CELERY CONFIGURATION diff --git a/gnuviechadmin/gvacommon/.gitignore b/gnuviechadmin/gvacommon/.gitignore index 3bb2efd..5f1ace6 100644 --- a/gnuviechadmin/gvacommon/.gitignore +++ b/gnuviechadmin/gvacommon/.gitignore @@ -1,2 +1,3 @@ .*.swp *.pyc +.ropeproject/ diff --git a/gnuviechadmin/gvacommon/celeryrouters.py b/gnuviechadmin/gvacommon/celeryrouters.py index e468813..ec7b122 100644 --- a/gnuviechadmin/gvacommon/celeryrouters.py +++ b/gnuviechadmin/gvacommon/celeryrouters.py @@ -2,23 +2,14 @@ from __future__ import unicode_literals -class LdapRouter(object): +class GvaRouter(object): def route_for_task(self, task, args=None, kwargs=None): - if 'ldap' in task: - return {'exchange': 'ldap', + for route in ['ldap', 'file', 'mysql', 'pgsql']: + if route in task: + return { + 'exchange': route, 'exchange_type': 'direct', - 'queue': 'ldap'} + 'queue': route, + } return None - - -class FileRouter(object): - - def route_for_task(self, task, args=None, kwargs=None): - if 'file' in task: - return {'exchange': 'file', - 'exchange_type': 'direct', - 'queue': 'file'} - return None - - diff --git a/gnuviechadmin/managemails/locale/de/LC_MESSAGES/django.po b/gnuviechadmin/managemails/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..442c816 --- /dev/null +++ b/gnuviechadmin/managemails/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,60 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: managemails\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-12-27 22:45+0100\n" +"PO-Revision-Date: 2014-12-27 22:57+0100\n" +"Last-Translator: Jan Dittberner \n" +"Language-Team: de \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 1.6.10\n" +"X-Poedit-SourceCharset: UTF-8\n" + +#: admin.py:14 +msgid "Passwords don't match" +msgstr "Passwörter stimmen nicht überein" + +#: admin.py:21 tests/test_admin.py:37 +msgid "Hash" +msgstr "Hash-Code" + +#: admin.py:44 +msgid "Password" +msgstr "Passwort" + +#: admin.py:46 +msgid "Password (again)" +msgstr "Passwortwiederholung" + +#: admin.py:100 +msgid "Activate" +msgstr "Aktivieren" + +#: admin.py:101 +msgid "Deactivate" +msgstr "Deaktivieren" + +#: models.py:51 +msgid "Mailbox" +msgstr "Postfach" + +#: models.py:52 +msgid "Mailboxes" +msgstr "Postfächer" + +#: models.py:76 +msgid "Mail address" +msgstr "E-Mailadresse" + +#: models.py:77 +msgid "Mail addresses" +msgstr "E-Mailadressen" diff --git a/gnuviechadmin/mysqltasks/__init__.py b/gnuviechadmin/mysqltasks/__init__.py new file mode 100644 index 0000000..9bc503f --- /dev/null +++ b/gnuviechadmin/mysqltasks/__init__.py @@ -0,0 +1,4 @@ +""" +This module contains :py:mod:`mysqltasks.tasks`. + +""" diff --git a/gnuviechadmin/mysqltasks/models.py b/gnuviechadmin/mysqltasks/models.py new file mode 100644 index 0000000..cf80bcf --- /dev/null +++ b/gnuviechadmin/mysqltasks/models.py @@ -0,0 +1,4 @@ +""" +Empty models to make Django accept mysqltasks as an app. + +""" diff --git a/gnuviechadmin/mysqltasks/tasks.py b/gnuviechadmin/mysqltasks/tasks.py new file mode 100644 index 0000000..cda59d4 --- /dev/null +++ b/gnuviechadmin/mysqltasks/tasks.py @@ -0,0 +1,72 @@ +""" +This module defines Celery_ tasks to manage MySQL users and databases. + +""" +from __future__ import absolute_import + +from celery import shared_task + + +@shared_task +def create_mysql_user(username, password): + """ + This task creates a new MySQL user. + + :param str username: the user name + :param str password: the password + :return: the created user's name + :rtype: str + + """ + + +@shared_task +def set_mysql_userpassword(username, password): + """ + Set a new password for an existing MySQL user. + + :param str username: the user name + :param str password: the password + :return: True if the password could be set, False otherwise + :rtype: boolean + + """ + + +@shared_task +def delete_mysql_user(username): + """ + This task deletes an existing MySQL user. + + :param str username: the user name + :return: True if the user has been deleted, False otherwise + :rtype: boolean + + """ + + +@shared_task +def create_mysql_database(dbname, username): + """ + This task creates a new MySQL database for the given MySQL user. + + :param str dbname: database name + :param str username: the user name of an existing MySQL user + :return: the database name + :rtype: str + + """ + + +@shared_task +def delete_mysql_database(dbname, username): + """ + This task deletes an existing MySQL database and revokes privileges of the + given user on that database. + + :param str dbname: database name + :param str username: the user name of an existing MySQL user + :return: True if the database has been deleted, False otherwise + :rtype: boolean + + """ diff --git a/gnuviechadmin/osusers/__init__.py b/gnuviechadmin/osusers/__init__.py index e69de29..24380a5 100644 --- a/gnuviechadmin/osusers/__init__.py +++ b/gnuviechadmin/osusers/__init__.py @@ -0,0 +1,5 @@ +""" +This app is for managing operating system users and groups. + +""" +default_app_config = 'osusers.apps.OsusersAppConfig' diff --git a/gnuviechadmin/osusers/admin.py b/gnuviechadmin/osusers/admin.py index a46dbc5..3bb3579 100644 --- a/gnuviechadmin/osusers/admin.py +++ b/gnuviechadmin/osusers/admin.py @@ -1,3 +1,7 @@ +""" +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.contrib import admin @@ -10,13 +14,24 @@ from .models import ( ) PASSWORD_MISMATCH_ERROR = _("Passwords don't match") +""" +Error message for non matching passwords. +""" class AdditionalGroupInline(admin.TabularInline): + """ + Inline for :py:class:`osusers.models.AdditionalGroup` instances. + + """ model = AdditionalGroup class ShadowInline(admin.TabularInline): + """ + Inline for :py:class:`osusers.models.ShadowInline` instances. + + """ model = Shadow readonly_fields = ['passwd'] can_delete = False @@ -24,22 +39,30 @@ class ShadowInline(admin.TabularInline): class UserCreationForm(forms.ModelForm): """ - A form for creating system users. + A form for creating :py:class:`operating system users + `. """ - password1 = forms.CharField(label=_('Password'), - widget=forms.PasswordInput) - password2 = forms.CharField(label=_('Password (again)'), - widget=forms.PasswordInput) + password1 = forms.CharField( + label=_('Password'), widget=forms.PasswordInput, + required=False, + ) + password2 = forms.CharField( + label=_('Password (again)'), widget=forms.PasswordInput, + required=False, + ) class Meta: model = User - fields = [] + fields = ['customer'] def clean_password2(self): """ Check that the two password entries match. + :return: the validated password + :rtype: str or None + """ password1 = self.cleaned_data.get('password1') password2 = self.cleaned_data.get('password2') @@ -51,8 +74,13 @@ class UserCreationForm(forms.ModelForm): """ Save the provided password in hashed format. + :param boolean commit: whether to save the created user + :return: user instance + :rtype: :py:class:`osusers.models.User` + """ user = User.objects.create_user( + customer=self.cleaned_data['customer'], password=self.cleaned_data['password1'], commit=commit) return user @@ -60,10 +88,16 @@ class UserCreationForm(forms.ModelForm): """ No additional groups are created when this form is saved, so this method just does nothing. + """ class UserAdmin(admin.ModelAdmin): + """ + Admin class for working with :py:class:`operating system users + `. + + """ actions = ['perform_delete_selected'] add_form = UserCreationForm inlines = [AdditionalGroupInline, ShadowInline] @@ -71,13 +105,20 @@ class UserAdmin(admin.ModelAdmin): add_fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('password1', 'password2')}), + 'fields': ('customer', 'password1', 'password2')}), ) def get_form(self, request, obj=None, **kwargs): """ Use special form during user creation. + :param request: the current HTTP request + :param obj: either a :py:class:`User ` instance or + None for a new user + :param kwargs: keyword arguments to be passed to + :py:meth:`django.contrib.admin.ModelAdmin.get_form` + :return: form instance + """ defaults = {} if obj is None: @@ -89,16 +130,47 @@ class UserAdmin(admin.ModelAdmin): return super(UserAdmin, self).get_form(request, obj, **defaults) def get_readonly_fields(self, request, obj=None): + """ + Make sure that uid is not editable for existing users. + + :param request: the current HTTP request + :param obj: either a :py:class:`User ` instance or + None for a new user + :return: a list of fields + :rtype: list + + """ if obj: return ['uid'] return [] def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected users. + + This action calls the delete method of each selected user in contrast + to the default `delete_selected`. + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected users + + """ for user in queryset.all(): user.delete() perform_delete_selected.short_description = _('Delete selected users') def get_actions(self, request): + """ + Get the available actions for users. + + 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(UserAdmin, self).get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] @@ -106,19 +178,40 @@ class UserAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin): + """ + Admin class for workint with :py:class:`operating system groups + `. + + """ actions = ['perform_delete_selected'] - def get_inline_instances(self, request, obj=None): - if obj is None: - return [] - return super(GroupAdmin, self).get_inline_instances(request, obj) - def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected groups. + + This action calls the delete method of each selected group in contrast + to the default `delete_selected`. + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected groups + + """ for group in queryset.all(): group.delete() perform_delete_selected.short_description = _('Delete selected groups') def get_actions(self, request): + """ + Get the available actions for groups. + + 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(GroupAdmin, self).get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] diff --git a/gnuviechadmin/osusers/apps.py b/gnuviechadmin/osusers/apps.py new file mode 100644 index 0000000..26f373d --- /dev/null +++ b/gnuviechadmin/osusers/apps.py @@ -0,0 +1,17 @@ +""" +This module contains the :py:class:`django.apps.AppConfig` instance for the +:py:mod:`osusers` app. + +""" +from __future__ import unicode_literals +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class OsusersAppConfig(AppConfig): + """ + AppConfig for the :py:mod:`osusers` app. + + """ + name = 'osusers' + verbose_name = _('Operating System Users and Groups') diff --git a/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po b/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..b6919a5 --- /dev/null +++ b/gnuviechadmin/osusers/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,175 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: osusers\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-12-27 22:46+0100\n" +"PO-Revision-Date: 2014-12-27 22:54+0100\n" +"Last-Translator: Jan Dittberner \n" +"Language-Team: de \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 1.6.10\n" +"X-Poedit-SourceCharset: UTF-8\n" + +#: admin.py:16 +msgid "Passwords don't match" +msgstr "Passwörter stimmen nicht überein" + +#: admin.py:47 +msgid "Password" +msgstr "Passwort" + +#: admin.py:51 +msgid "Password (again)" +msgstr "Passwortwiederholung" + +#: admin.py:160 +msgid "Delete selected users" +msgstr "Ausgewählte Nutzer löschen" + +#: admin.py:201 +msgid "Delete selected groups" +msgstr "Ausgewählte Gruppen löschen" + +#: apps.py:17 +msgid "Operating System Users and Groups" +msgstr "Betriebssystemnutzer- und Gruppen" + +#: models.py:41 +msgid "You can not use a user's primary group." +msgstr "Sie können nicht die primäre Gruppe des Nutzers verwenden." + +#: models.py:71 +msgid "Group name" +msgstr "Gruppenname" + +#: models.py:73 +msgid "Group ID" +msgstr "Gruppen-ID" + +#: models.py:74 +msgid "Description" +msgstr "Beschreibung" + +#: models.py:76 +msgid "Group password" +msgstr "Gruppenpasswort" + +#: models.py:81 models.py:212 +msgid "Group" +msgstr "Gruppe" + +#: models.py:82 +msgid "Groups" +msgstr "Gruppen" + +#: models.py:209 +msgid "User name" +msgstr "Nutzername" + +#: models.py:211 +msgid "User ID" +msgstr "Nutzer-ID" + +#: models.py:213 +msgid "Gecos field" +msgstr "GECOS-Feld" + +#: models.py:214 +msgid "Home directory" +msgstr "Home-Verzeichnis" + +#: models.py:215 +msgid "Login shell" +msgstr "Loginshell" + +#: models.py:221 models.py:335 +msgid "User" +msgstr "Nutzer" + +#: models.py:222 +msgid "Users" +msgstr "Nutzer" + +#: models.py:336 +msgid "Encrypted password" +msgstr "Verschlüsseltes Passwort" + +#: models.py:338 +msgid "Date of last change" +msgstr "Datum der letzten Änderung" + +#: models.py:339 +msgid "This is expressed in days since Jan 1, 1970" +msgstr "Ausgedrückt als Tage seit dem 1. Januar 1970" + +#: models.py:342 +msgid "Minimum age" +msgstr "Minimales Alter" + +#: models.py:343 +msgid "Minimum number of days before the password can be changed" +msgstr "Minmale Anzahl von Tagen bevor das Passwort geändert werden kann" + +#: models.py:347 +msgid "Maximum age" +msgstr "Maximales Alter" + +#: models.py:348 +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" + +#: models.py:352 +msgid "Grace period" +msgstr "Duldungsperiode" + +#: models.py:353 +msgid "The number of days before the password is going to expire" +msgstr "Anzahl von Tagen nach denen das Passwort verfällt" + +#: models.py:357 +msgid "Inactivity period" +msgstr "Inaktivitätsperiode" + +#: models.py:358 +msgid "" +"The number of days after the password has expired during which the password " +"should still be accepted" +msgstr "" +"Die Anzahl von Tagen für die ein verfallenes Passwort noch akzeptiert werden " +"soll" + +#: models.py:362 +msgid "Account expiration date" +msgstr "Kontoverfallsdatum" + +#: models.py:363 +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" + +#: models.py:370 +msgid "Shadow password" +msgstr "Shadow-Passwort" + +#: models.py:371 +msgid "Shadow passwords" +msgstr "Shadow-Passwörter" + +#: models.py:397 +msgid "Additional group" +msgstr "Weitere Gruppe" + +#: models.py:398 +msgid "Additional groups" +msgstr "Weitere Gruppen" diff --git a/gnuviechadmin/osusers/migrations/0003_user_customer.py b/gnuviechadmin/osusers/migrations/0003_user_customer.py new file mode 100644 index 0000000..b8043ef --- /dev/null +++ b/gnuviechadmin/osusers/migrations/0003_user_customer.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('osusers', '0002_auto_20141226_1456'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='customer', + field=models.ForeignKey(default=1, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/gnuviechadmin/osusers/migrations/0004_auto_20150104_1751.py b/gnuviechadmin/osusers/migrations/0004_auto_20150104_1751.py new file mode 100644 index 0000000..b1dcd60 --- /dev/null +++ b/gnuviechadmin/osusers/migrations/0004_auto_20150104_1751.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osusers', '0003_user_customer'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'verbose_name': 'User', 'verbose_name_plural': 'Users'}, + ), + migrations.AlterField( + model_name='shadow', + name='user', + field=models.OneToOneField(primary_key=True, serialize=False, to='osusers.User', verbose_name='User'), + preserve_default=True, + ), + ] diff --git a/gnuviechadmin/osusers/models.py b/gnuviechadmin/osusers/models.py index 93a4c3d..8b5edfe 100644 --- a/gnuviechadmin/osusers/models.py +++ b/gnuviechadmin/osusers/models.py @@ -1,3 +1,7 @@ +""" +This module defines the database models of operating system users. + +""" from __future__ import unicode_literals from datetime import date @@ -16,13 +20,15 @@ from model_utils.models import TimeStampedModel from passlib.hash import sha512_crypt from passlib.utils import generate_password +from taskresults.models import TaskResult + from .tasks import ( add_ldap_user_to_group, create_ldap_group, create_ldap_user, delete_file_mail_userdir, delete_file_sftp_userdir, - delete_ldap_group_if_empty, + delete_ldap_group, delete_ldap_user, remove_ldap_user_from_group, setup_file_mail_userdir, @@ -30,7 +36,7 @@ from .tasks import ( ) -logger = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL = _( @@ -38,8 +44,19 @@ CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL = _( class GroupManager(models.Manager): + """ + Manager class for :py:class:`osusers.models.Group`. + + """ def get_next_gid(self): + """ + Get the next available group id. + + :returns: group id + :rtype: int + + """ q = self.aggregate(models.Max('gid')) if q['gid__max'] is None: return settings.OSUSER_MINGID @@ -48,6 +65,10 @@ class GroupManager(models.Manager): @python_2_unicode_compatible class Group(TimeStampedModel, models.Model): + """ + This entity class corresponds to an operating system group. + + """ groupname = models.CharField( _('Group name'), max_length=16, unique=True) gid = models.PositiveSmallIntegerField( @@ -67,34 +88,76 @@ class Group(TimeStampedModel, models.Model): @transaction.atomic def save(self, *args, **kwargs): + """ + Save the group to the database and synchronizes group information to + LDAP. + + :param args: positional arguments to be passed on to + :py:meth:`django.db.Model.save` + :param kwargs: keyword arguments to be passed on to + :py:meth:`django.db.Model.save` + :return: self + :rtype: :py:class:`osusers.models.Group` + + """ super(Group, self).save(*args, **kwargs) dn = create_ldap_group.delay( self.groupname, self.gid, self.descr).get() - logger.info("created LDAP group with dn %s", dn) + _LOGGER.info("created LDAP group with dn %s", dn) return self @transaction.atomic def delete(self, *args, **kwargs): - delete_ldap_group_if_empty.delay(self.groupname).get() + """ + Delete the group from LDAP and the database. + + :param args: positional arguments to be passed on to + :py:meth:`django.db.Model.delete` + :param kwargs: keyword arguments to be passed on to + :py:meth:`django.db.Model.delete` + + """ + TaskResult.objects.create_task_result( + delete_ldap_group.delay(self.groupname), + 'delete_ldap_group' + ) super(Group, self).delete(*args, **kwargs) class UserManager(models.Manager): + """ + Manager class for :py:class:`osusers.models.User`. + + """ def get_next_uid(self): + """ + Get the next available user id. + + :return: user id + :rtype: int + + """ q = self.aggregate(models.Max('uid')) if q['uid__max'] is None: return settings.OSUSER_MINUID return max(settings.OSUSER_MINUID, q['uid__max'] + 1) def get_next_username(self): + """ + Get the next available user name. + + :return: user name + :rtype: str + + """ count = 1 usernameformat = "{0}{1:02d}" nextuser = usernameformat.format(settings.OSUSER_USERNAME_PREFIX, count) for user in self.values('username').filter( - username__startswith=settings.OSUSER_USERNAME_PREFIX).order_by( - 'username'): + username__startswith=settings.OSUSER_USERNAME_PREFIX + ).order_by('username'): if user['username'] == nextuser: count += 1 nextuser = usernameformat.format( @@ -104,7 +167,26 @@ class UserManager(models.Manager): return nextuser @transaction.atomic - def create_user(self, username=None, password=None, commit=False): + def create_user( + self, customer, username=None, password=None, commit=False + ): + """ + Create a new user with a primary group named the same as the user and + an initial password. + + If username is None the result of :py:meth:`get_next_username` is used. + If password is None a new password will be generated using passlib's + :py:func:`generate_password`. + + :param customer: Django User instance this user is associated to + :param str username: the username or None + :param str password: the password or None + :param boolean commit: whether to commit the user data to the database + or not + :return: new user + :rtype: :py:class:`osusers.models.User` + + """ uid = self.get_next_uid() gid = Group.objects.get_next_gid() if username is None: @@ -114,7 +196,7 @@ class UserManager(models.Manager): homedir = os.path.join(settings.OSUSER_HOME_BASEPATH, username) group = Group.objects.create(groupname=username, gid=gid) user = self.create(username=username, group=group, uid=uid, - homedir=homedir, + homedir=homedir, customer=customer, shell=settings.OSUSER_DEFAULT_SHELL) user.set_password(password) if commit: @@ -124,6 +206,10 @@ class UserManager(models.Manager): @python_2_unicode_compatible class User(TimeStampedModel, models.Model): + """ + This entity class corresponds to an operating system user. + + """ username = models.CharField( _('User name'), max_length=64, unique=True) uid = models.PositiveSmallIntegerField( @@ -132,6 +218,7 @@ class User(TimeStampedModel, models.Model): gecos = models.CharField(_('Gecos field'), max_length=128, blank=True) homedir = models.CharField(_('Home directory'), max_length=256) shell = models.CharField(_('Login shell'), max_length=64) + customer = models.ForeignKey(settings.AUTH_USER_MODEL) objects = UserManager() @@ -144,6 +231,15 @@ class User(TimeStampedModel, models.Model): @transaction.atomic def set_password(self, password): + """ + Set the password of the user. + + The password is set to the user's + :py:class:`Shadow ` instance and to LDAP. + + :param str password: the new password + + """ if hasattr(self, 'shadow'): self.shadow.set_password(password) else: @@ -158,23 +254,56 @@ class User(TimeStampedModel, models.Model): @transaction.atomic def save(self, *args, **kwargs): + """ + Save the user to the database, create user directories and synchronize + user information to LDAP. + + :param args: positional arguments to be passed on to + :py:meth:`django.db.Model.save` + :param kwargs: keyword arguments to be passed on to + :py:meth:`django.db.Model.save` + :return: self + :rtype: :py:class:`osusers.models.User` + + """ dn = create_ldap_user.delay( self.username, self.uid, self.group.gid, self.gecos, self.homedir, self.shell, password=None).get() - sftp_dir = setup_file_sftp_userdir.delay(self.username).get() - mail_dir = setup_file_mail_userdir.delay(self.username).get() - logger.info( - "created user %(user)s with LDAP dn %(dn)s, home directory " - "%(homedir)s and mail base directory %(maildir)s.", { + TaskResult.objects.create_task_result( + setup_file_sftp_userdir.delay(self.username), + 'setup_file_sftp_userdir' + ) + TaskResult.objects.create_task_result( + setup_file_mail_userdir.delay(self.username), + 'setup_file_mail_userdir' + ) + _LOGGER.info( + "created user %(user)s with LDAP dn %(dn)s, scheduled home " + "directory and mail base directory creation.", { 'user': self, 'dn': dn, - 'homedir': sftp_dir, 'maildir': mail_dir }) return super(User, self).save(*args, **kwargs) @transaction.atomic def delete(self, *args, **kwargs): - delete_file_mail_userdir.delay(self.username).get() - delete_file_sftp_userdir.delay(self.username).get() + """ + Delete the user and its groups from LDAP and the database and remove + the user's directories. + + :param args: positional arguments to be passed on to + :py:meth:`django.db.Model.delete` + :param kwargs: keyword arguments to be passed on to + :py:meth:`django.db.Model.delete` + + """ + TaskResult.objects.create_task_result( + delete_file_mail_userdir.delay(self.username), + 'delete_file_mail_userdir' + ) + TaskResult.objects.create_task_result( + delete_file_sftp_userdir.delay(self.username), + 'delete_file_sftp_userdir' + ) for group in [ag.group for ag in self.additionalgroup_set.all()]: remove_ldap_user_from_group.delay( self.username, group.groupname).get() @@ -184,9 +313,23 @@ class User(TimeStampedModel, models.Model): class ShadowManager(models.Manager): + """ + Manager class for :py:class:`osusers.models.Shadow`. + + """ @transaction.atomic def create_shadow(self, user, password): + """ + Create a new shadow instance with typical Linux settings for the given + user with the given password. + + :param user: :py:class:`User ` instance + :param str password: the password + :return: new Shadow instance + :rtype: :py:class:`osusers.models.Shadow` instance + + """ changedays = (timezone.now().date() - date(1970, 1, 1)).days shadow = self.create( user=user, changedays=changedays, @@ -200,6 +343,11 @@ class ShadowManager(models.Manager): @python_2_unicode_compatible class Shadow(TimeStampedModel, models.Model): + """ + This entity class corresponds to an operating system user's shadow file + entry. + + """ user = models.OneToOneField(User, primary_key=True, verbose_name=_('User')) passwd = models.CharField(_('Encrypted password'), max_length=128) changedays = models.PositiveSmallIntegerField( @@ -242,11 +390,21 @@ class Shadow(TimeStampedModel, models.Model): return 'for user {0}'.format(self.user) def set_password(self, password): + """ + Set and encrypt the password. + + :param str password: the password + """ self.passwd = sha512_crypt.encrypt(password) @python_2_unicode_compatible class AdditionalGroup(TimeStampedModel, models.Model): + """ + This entity class corresponds to additional group assignments for an + :py:class:`operating system user `. + + """ user = models.ForeignKey(User) group = models.ForeignKey(Group) @@ -255,21 +413,48 @@ class AdditionalGroup(TimeStampedModel, models.Model): verbose_name = _('Additional group') verbose_name_plural = _('Additional groups') + def __str__(self): + return '{0} in {1}'.format(self.user, self.group) + def clean(self): + """ + Ensure that the assigned group is different from the user's primary + group. + + """ if self.user.group == self.group: raise ValidationError(CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL) @transaction.atomic def save(self, *args, **kwargs): + """ + Persists the group assignment to LDAP and the database. + + :param args: positional arguments to be passed on to + :py:meth:`django.db.Model.save` + :param kwargs: keyword arguments to be passed on to + :py:meth:`django.db.Model.save` + :return: this instance + :rtype: :py:class:`AdditionalGroup ` + + """ add_ldap_user_to_group.delay( self.user.username, self.group.groupname).get() - super(AdditionalGroup, self).save(*args, **kwargs) + return super(AdditionalGroup, self).save(*args, **kwargs) @transaction.atomic def delete(self, *args, **kwargs): - remove_ldap_user_from_group.delay( - self.user.username, self.group.groupname).get() - super(AdditionalGroup, self).delete(*args, **kwargs) + """ + Delete the group assignment from LDAP and the database. - def __str__(self): - return '{0} in {1}'.format(self.user, self.group) + :param args: positional arguments to be passed on to + :py:meth:`django.db.Model.delete` + :param kwargs: keyword arguments to be passed on to + :py:meth:`django.db.Model.delete` + """ + TaskResult.objects.create_task_result( + remove_ldap_user_from_group.delay( + self.user.username, self.group.groupname), + 'remove_ldap_user_from_group' + ) + super(AdditionalGroup, self).delete(*args, **kwargs) diff --git a/gnuviechadmin/osusers/tasks.py b/gnuviechadmin/osusers/tasks.py index 99c7b1c..23cc612 100644 --- a/gnuviechadmin/osusers/tasks.py +++ b/gnuviechadmin/osusers/tasks.py @@ -1,3 +1,8 @@ +""" +This module defines task stubs for the tasks implemented on the Celery +workers. + +""" from __future__ import absolute_import from celery import shared_task @@ -5,59 +10,194 @@ from celery import shared_task @shared_task def create_ldap_group(groupname, gid, descr): - pass + """ + This task creates an :py:class:`LDAP group ` + if it does not exist yet. + + If a group with the given name exists its group id and description + attributes are updated. + + :param str groupname: the group name + :param int gid: the group id + :param str descr: description text for the group + :return: the distinguished name of the group + :rtype: str + """ @shared_task def create_ldap_user(username, uid, gid, gecos, homedir, shell, password): - pass + """ + This task creates an :py:class:`LDAP user ` + if it does not exist yet. + + The task is rejected if the primary group of the user is not defined. + + The user's fields are updated if the user already exists. + + :param str username: the user name + :param int uid: the user id + :param int gid: the user's primary group's id + :param str gecos: the text for the GECOS field + :param str homedir: the user's home directory + :param str shell: the user's login shell + :param str or None password: the clear text password, if :py:const:`None` + is passed the password is not touched + :raises celery.exceptions.Reject: if the specified primary group does not + exist + :return: the distinguished name of the user + :rtype: str + + """ @shared_task def add_ldap_user_to_group(username, groupname): - pass + """ + This task adds the specified user to the given group. + + This task does nothing if the user is already member of the group. + + :param str username: the user name + :param str groupname: the group name + :raises celery.exceptions.Retry: if the user does not exist yet, + :py:func:`create_ldap_user` should be called before + :return: True if the user has been added to the group otherwise False + :rtype: boolean + + """ @shared_task def remove_ldap_user_from_group(username, groupname): - pass + """ + This task removes the given user from the given group. + + :param str username: the user name + :param str groupname: the group name + :return: True if the user has been removed, False otherwise + :rtype: boolean + + """ @shared_task def delete_ldap_user(username): - pass + """ + This task deletes the given user. + + :param str username: the user name + :return: True if the user has been deleted, False otherwise + :rtype: boolean + + """ @shared_task def delete_ldap_group_if_empty(groupname): - pass + """ + This task deletes the given group if it is empty. + + :param str groupname: the group name + :return: True if the user has been deleted, False otherwise + :rtype: boolean + + """ + + +@shared_task +def delete_ldap_group(groupname): + """ + This taks deletes the given group. + + :param str groupname: the group name + :return: True if the user has been deleted, False otherwise + :rtype: boolean + + """ @shared_task def setup_file_sftp_userdir(username): - pass + """ + This task creates the home directory for an SFTP user if it does not exist + yet. + + :param str username: the user name + :raises Exception: if the SFTP directory of the user cannot be created + :return: the created directory name + :rtype: str + + """ @shared_task def delete_file_sftp_userdir(username): - pass + """ + This task recursively deletes the home directory of an SFTP user if it + does not exist yet. + + :param str username: the user name + :raises Exception: if the SFTP directory of the user cannot be removed + :return: the removed directory name + :rtype: str + + """ @shared_task def setup_file_mail_userdir(username): - pass + """ + This task creates the mail base directory for a user if it does not exist + yet. + + :param str username: the user name + :raises Exception: if the mail base directory for the user cannot be + created + :return: the created directory name + :rtype: str + + """ @shared_task def delete_file_mail_userdir(username): - pass + """ + This task recursively deletes the mail base directory for a user if it + does not exist yet. + + :param str username: the user name + :raises Exception: if the mail base directory of the user cannot be removed + :return: the removed directory name + :rtype: str + + """ @shared_task def create_file_mailbox(username, mailboxname): - pass + """ + This task creates a new mailbox directory for the given user and mailbox + name. + + :param str username: the user name + :param str mailboxname: the mailbox name + :raises GVAFileException: if the mailbox directory cannot be created + :return: the created mailbox directory name + :rtype: str + + """ @shared_task def delete_file_mailbox(username, mailboxname): - pass + """ + This task deletes the given mailbox of the given user. + + :param str username: the user name + :param str mailboxname: the mailbox name + :raises GVAFileException: if the mailbox directory cannot be deleted + :return: the deleted mailbox directory name + :rtype: str + + """ diff --git a/gnuviechadmin/pgsqltasks/__init__.py b/gnuviechadmin/pgsqltasks/__init__.py new file mode 100644 index 0000000..c49a650 --- /dev/null +++ b/gnuviechadmin/pgsqltasks/__init__.py @@ -0,0 +1,4 @@ +""" +This module contains :py:mod:`pgsqltasks.tasks`. + +""" diff --git a/gnuviechadmin/pgsqltasks/models.py b/gnuviechadmin/pgsqltasks/models.py new file mode 100644 index 0000000..3a43087 --- /dev/null +++ b/gnuviechadmin/pgsqltasks/models.py @@ -0,0 +1,4 @@ +""" +Empty models to make Django accept pgsqltasks as an app. + +""" diff --git a/gnuviechadmin/pgsqltasks/tasks.py b/gnuviechadmin/pgsqltasks/tasks.py new file mode 100644 index 0000000..5aa40d6 --- /dev/null +++ b/gnuviechadmin/pgsqltasks/tasks.py @@ -0,0 +1,70 @@ +""" +This module defines Celery_ tasks to manage PostgreSQL users and databases. + +""" +from __future__ import absolute_import + +from celery import shared_task + + +@shared_task +def create_pgsql_user(username, password): + """ + This task creates a new PostgreSQL user. + + :param str username: the user name + :param str password: the password + :return: the created user's name + :rtype: str + + """ + + +@shared_task +def set_pgsql_userpassword(username, password): + """ + Set a new password for an existing PostgreSQL user. + + :param str username: the user name + :param str password: the password + :return: True if the password could be set, False otherwise + :rtype: boolean + + """ + + +@shared_task +def delete_pgsql_user(username): + """ + This task deletes an existing PostgreSQL user. + + :param str username: the user name + :return: True if the user has been deleted, False otherwise + :rtype: boolean + + """ + + +@shared_task +def create_pgsql_database(dbname, username): + """ + This task creates a new PostgreSQL database for the given PostgreSQL user. + + :param str dbname: database name + :param str username: the user name of an existing PostgreSQL user + :return: the database name + :rtype: str + + """ + + +@shared_task +def delete_pgsql_database(dbname): + """ + This task deletes an existing PostgreSQL database. + + :param str dbname: database name + :return: True if the database has been deleted, False otherwise + :rtype: boolean + + """ diff --git a/gnuviechadmin/taskresults/__init__.py b/gnuviechadmin/taskresults/__init__.py new file mode 100644 index 0000000..dace904 --- /dev/null +++ b/gnuviechadmin/taskresults/__init__.py @@ -0,0 +1,5 @@ +""" +This is the taskresults app that is used for storing the results from +asynchronous Celery_ tasks. + +""" diff --git a/gnuviechadmin/taskresults/admin.py b/gnuviechadmin/taskresults/admin.py new file mode 100644 index 0000000..cd47dc8 --- /dev/null +++ b/gnuviechadmin/taskresults/admin.py @@ -0,0 +1,12 @@ +""" +This module defines the admin interface for the taskresults app. + +""" +from __future__ import absolute_import + +from django.contrib import admin + +from .models import TaskResult + + +admin.site.register(TaskResult) diff --git a/gnuviechadmin/taskresults/management/__init__.py b/gnuviechadmin/taskresults/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gnuviechadmin/taskresults/management/commands/__init__.py b/gnuviechadmin/taskresults/management/commands/__init__.py new file mode 100644 index 0000000..4e6e692 --- /dev/null +++ b/gnuviechadmin/taskresults/management/commands/__init__.py @@ -0,0 +1,4 @@ +""" +This module defines management commands for the taskresults app. + +""" diff --git a/gnuviechadmin/taskresults/management/commands/fetch_taskresults.py b/gnuviechadmin/taskresults/management/commands/fetch_taskresults.py new file mode 100644 index 0000000..8ccbd78 --- /dev/null +++ b/gnuviechadmin/taskresults/management/commands/fetch_taskresults.py @@ -0,0 +1,20 @@ +""" +This model contains the implementation of a management command to fetch the +results of all `Celery `_ tasks that are not +marked as finished yet. + +""" +from __future__ import unicode_literals + +from django.core.management.base import BaseCommand + +from taskresults.models import TaskResult + + +class Command(BaseCommand): + help = "fetch task results" + + def handle(self, *args, **options): + for taskresult in TaskResult.objects.filter(finished=False): + taskresult.fetch_result() + taskresult.save() diff --git a/gnuviechadmin/taskresults/migrations/0001_initial.py b/gnuviechadmin/taskresults/migrations/0001_initial.py new file mode 100644 index 0000000..7c405be --- /dev/null +++ b/gnuviechadmin/taskresults/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TaskResult', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('task_id', models.CharField(max_length=36, verbose_name='Task id')), + ('task_name', models.CharField(max_length=64, verbose_name='Task name')), + ('result', models.TextField(verbose_name='Task result')), + ('finished', models.BooleanField(default=False)), + ('state', models.CharField(max_length=16, verbose_name='Task state')), + ], + options={ + 'verbose_name': 'Task result', + 'verbose_name_plural': 'Task results', + }, + bases=(models.Model,), + ), + ] diff --git a/gnuviechadmin/taskresults/migrations/__init__.py b/gnuviechadmin/taskresults/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gnuviechadmin/taskresults/models.py b/gnuviechadmin/taskresults/models.py new file mode 100644 index 0000000..7178c84 --- /dev/null +++ b/gnuviechadmin/taskresults/models.py @@ -0,0 +1,47 @@ +""" +This model defines the database models to handle Celery AsyncResults. + +""" +from __future__ import unicode_literals + +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext as _ + +from gnuviechadmin.celery import app + + +class TaskResultManager(models.Manager): + def create_task_result(self, asyncresult, name): + taskresult = self.create(task_id=asyncresult.id, task_name=name) + return taskresult + + +@python_2_unicode_compatible +class TaskResult(models.Model): + task_id = models.CharField(_('Task id'), max_length=36) + task_name = models.CharField(_('Task name'), max_length=64) + result = models.TextField(_('Task result')) + finished = models.BooleanField(default=False) + state = models.CharField(_('Task state'), max_length=16) + + objects = TaskResultManager() + + class Meta: + verbose_name = _('Task result') + verbose_name_plural = _('Task results') + + def __str__(self): + return "{task_name} ({task_id}): {finished}".format( + task_name=self.task_name, + task_id=self.task_id, + finished=_('yes') if self.finished else _('no') + ) + + def fetch_result(self): + if not self.finished: + ar = app.AsyncResult(self.task_id) + res = ar.get(no_ack=True, timeout=1) + self.result = str(res) + self.state = ar.state + self.finished = True diff --git a/gnuviechadmin/userdbs/__init__.py b/gnuviechadmin/userdbs/__init__.py new file mode 100644 index 0000000..6f1b8ad --- /dev/null +++ b/gnuviechadmin/userdbs/__init__.py @@ -0,0 +1,5 @@ +""" +This app is for managing database users and user databases. + +""" +default_app_config = 'userdbs.apps.UserdbsAppConfig' diff --git a/gnuviechadmin/userdbs/admin.py b/gnuviechadmin/userdbs/admin.py new file mode 100644 index 0000000..7a498c8 --- /dev/null +++ b/gnuviechadmin/userdbs/admin.py @@ -0,0 +1,279 @@ +""" +Admin functionality for the :py:mod:`userdbs.models` models. + +""" +from __future__ import absolute_import + +from django import forms +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + +from .models import ( + DatabaseUser, + UserDatabase, +) + + +class DatabaseUserCreationForm(forms.ModelForm): + """ + A form for creating :py:class:`database users + ` + + """ + + class Meta: + model = DatabaseUser + fields = ['osuser', 'db_type'] + + def save(self, commit=True): + """ + Save the database user. + + :param boolean commit: whether to save the created database user + :return: database user instance + :rtype: :py:class:`userdbs.models.DatabaseUser` + + """ + dbuser = DatabaseUser.objects.create_database_user( + osuser=self.cleaned_data['osuser'], + db_type=self.cleaned_data['db_type'], commit=commit) + return dbuser + + def save_m2m(self): + """ + Noop. + + """ + + +class UserDatabaseCreationForm(forms.ModelForm): + """ + A form for creating :py:class:`user databases + ` + + """ + + class Meta: + model = UserDatabase + fields = ['db_user'] + + def save(self, commit=True): + """ + Save the user database. + + :param boolean commit: whether to save the created user database + :return: user database instance + :rtype: :py:class:`userdbs.models.UserDatabase` + + """ + database = UserDatabase.objects.create_userdatabase( + db_user=self.cleaned_data['db_user'], commit=commit) + return database + + def save_m2m(self): + """ + Noop. + + """ + + +class DatabaseUserAdmin(admin.ModelAdmin): + """ + Admin class for working with :py:class:`database users + ` + + """ + actions = ['perform_delete_selected'] + add_form = DatabaseUserCreationForm + + def get_form(self, request, obj=None, **kwargs): + """ + Use special form for database user creation. + + :param request: the current HTTP request + :param obj: either a :py:class:`Database user + ` instance or None for a new database + user + :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, + }) + defaults.update(kwargs) + return super(DatabaseUserAdmin, self).get_form( + request, obj, **defaults) + + def get_readonly_fields(self, request, obj=None): + """ + Make sure that osuser, name and db_type are not editable for existing + database users. + + :param request: the current HTTP request + :param obj: either a :py:class:`Database user + ` instance or None for a new database + user + :return: a list of fields + :rtype: list + + """ + if obj: + return ['osuser', 'name', 'db_type'] + return [] + + def save_model(self, request, obj, form, change): + """ + Make sure that the user is created in the target database. + + :param request: the current HTTP request + :param obj: a :py:class:`Database user ` + instance + :param form: the form instance + :param boolean change: whether this is a change operation or not + + """ + if not change: + obj.create_in_database() + super(DatabaseUserAdmin, self).save_model(request, obj, form, change) + + def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected database users. + + This action calls the delete method of each selected database user in + contrast to the default `delete_selected` + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected database + users + + """ + for dbuser in queryset.all(): + dbuser.delete() + perform_delete_selected.short_description = _( + 'Delete selected database users') + + def get_actions(self, request): + """ + Get the available actions for database users. + + 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(DatabaseUserAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + +class UserDatabaseAdmin(admin.ModelAdmin): + """ + Admin class for working with :py:class:`user databases + ` + + """ + actions = ['perform_delete_selected'] + add_form = UserDatabaseCreationForm + + def get_form(self, request, obj=None, **kwargs): + """ + Use special form for user database creation. + + :param request: the current HTTP request + :param obj: either a :py:class:`User database + ` instance or None for a new user + database + :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, + }) + defaults.update(kwargs) + return super(UserDatabaseAdmin, self).get_form( + request, obj, **defaults) + + def get_readonly_fields(self, request, obj=None): + """ + Make sure that db_name and db_user are not editable for existing user + databases. + + :param request: the current HTTP request + :param obj: either a :py:class:`User database + ` instance or None for a new user + database + :return: a list of fields + :rtype: list + + """ + if obj: + return ['db_name', 'db_user'] + return [] + + def save_model(self, request, obj, form, change): + """ + Make sure that the database is created in the target database server. + + :param request: the current HTTP request + :param obj: a :py:class:`Database user ` + instance + :param form: the form instance + :param boolean change: whether this is a change operation or not + + """ + if not change: + obj.create_in_database() + super(UserDatabaseAdmin, self).save_model(request, obj, form, change) + + def perform_delete_selected(self, request, queryset): + """ + Action to delete a list of selected user databases. + + This action calls the delete method of each selected user database in + contrast to the default `delete_selected` + + :param request: the current HTTP request + :param queryset: Django ORM queryset representing the selected user + databases + + """ + for dbuser in queryset.all(): + dbuser.delete() + for database in queryset.all(): + database.delete() + perform_delete_selected.short_description = _( + 'Delete selected user databases') + + def get_actions(self, request): + """ + Get the available actions for user databases. + + 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(UserDatabaseAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + +admin.site.register(DatabaseUser, DatabaseUserAdmin) +admin.site.register(UserDatabase, UserDatabaseAdmin) diff --git a/gnuviechadmin/userdbs/apps.py b/gnuviechadmin/userdbs/apps.py new file mode 100644 index 0000000..40fd5ec --- /dev/null +++ b/gnuviechadmin/userdbs/apps.py @@ -0,0 +1,18 @@ +""" +This module contains the :py:class:`django.apps.AppConfig` instance for the +:py:mod:`userdbs` app. + +""" +from __future__ import unicode_literals + +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class UserdbsAppConfig(AppConfig): + """ + AppConfig for the :py:mod:`userdbs` app. + + """ + name = 'userdbs' + verbose_name = _('Database Users and their Databases') diff --git a/gnuviechadmin/userdbs/migrations/0001_initial.py b/gnuviechadmin/userdbs/migrations/0001_initial.py new file mode 100644 index 0000000..7a81c3c --- /dev/null +++ b/gnuviechadmin/userdbs/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# -*- 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='DatabaseUser', + 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)), + ('name', models.CharField(max_length=63, verbose_name='username')), + ('db_type', models.PositiveSmallIntegerField(verbose_name='database type', choices=[(0, 'PostgreSQL'), (1, 'MySQL')])), + ('osuser', models.ForeignKey(to='osusers.User')), + ], + options={ + 'verbose_name': 'database user', + 'verbose_name_plural': 'database users', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserDatabase', + 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)), + ('db_name', models.CharField(max_length=63, verbose_name='database name')), + ('db_user', models.ForeignKey(verbose_name='database user', to='userdbs.DatabaseUser')), + ], + options={ + 'verbose_name': 'user database', + 'verbose_name_plural': 'user specific database', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='userdatabase', + unique_together=set([('db_name', 'db_user')]), + ), + migrations.AlterUniqueTogether( + name='databaseuser', + unique_together=set([('name', 'db_type')]), + ), + ] diff --git a/gnuviechadmin/userdbs/migrations/__init__.py b/gnuviechadmin/userdbs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gnuviechadmin/userdbs/models.py b/gnuviechadmin/userdbs/models.py new file mode 100644 index 0000000..c678a31 --- /dev/null +++ b/gnuviechadmin/userdbs/models.py @@ -0,0 +1,276 @@ +from __future__ import unicode_literals + +from django.db import models +from django.db import transaction +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext as _ + +from model_utils import Choices +from model_utils.models import TimeStampedModel + +from passlib.utils import generate_password + +from osusers.models import User as OsUser + +from mysqltasks.tasks import ( + create_mysql_database, + create_mysql_user, + delete_mysql_database, + delete_mysql_user, + set_mysql_userpassword, +) +from pgsqltasks.tasks import ( + create_pgsql_database, + create_pgsql_user, + delete_pgsql_database, + delete_pgsql_user, + set_pgsql_userpassword, +) + + +DB_TYPES = Choices( + (0, 'pgsql', _('PostgreSQL')), + (1, 'mysql', _('MySQL')), +) +""" +Database type choice enumeration. +""" + + +class DatabaseUserManager(models.Manager): + """ + Default Manager for :py:class:`userdbs.models.DatabaseUser`. + + """ + + def _get_next_dbuser_name(self, osuser, db_type): + """ + Get the next available database user name. + + :param osuser: :py:class:`osusers.models.User` instance + :param db_type: value from :py:data:`DB_TYPES` + :return: database user name + :rtype: str + """ + count = 1 + dbuser_name_format = "{0}db{{0:02d}}".format(osuser.username) + nextname = dbuser_name_format.format(count) + + for user in self.values('name').filter( + osuser=osuser, db_type=db_type + ).order_by('name'): + if user['name'] == nextname: + count += 1 + nextname = dbuser_name_format.format(count) + else: + break + return nextname + + @transaction.atomic + def create_database_user( + self, osuser, db_type, username=None, password=None, commit=True + ): + """ + Create a database user of the given type for the given OS user. + + If username or password are not specified they are generated. + + :param osuser: the :py:class:`osusers.models.User` instance + :param db_type: value from :py:data:`DB_TYPES` + :param str username: database user name + :param str password: initial password or None + :param boolean commit: whether the user should be persisted + :return: :py:class:`userdbs.models.DatabaseUser` instance + + .. note:: + + The password is not persisted it is only used to set the password + on the database side. + + """ + if username is None: + username = self._get_next_dbuser_name(osuser, db_type) + db_user = DatabaseUser( + osuser=osuser, db_type=db_type, name=username) + if commit: + db_user.create_in_database(password=password) + db_user.save() + return db_user + + + +@python_2_unicode_compatible +class DatabaseUser(TimeStampedModel, models.Model): + osuser = models.ForeignKey(OsUser) + name = models.CharField( + _('username'), max_length=63) + db_type = models.PositiveSmallIntegerField( + _('database type'), choices=DB_TYPES) + + objects = DatabaseUserManager() + + class Meta: + unique_together = ['name', 'db_type'] + verbose_name = _('database user') + verbose_name_plural = _('database users') + + def __str__(self): + return "%(name)s (%(db_type)s for %(osuser)s)" % { + 'name': self.name, + 'db_type': self.get_db_type_display(), + 'osuser': self.osuser.username, + } + + def create_in_database(self, password=None): + """ + Create this user in the target database. + + :param str password: initial password for the database user + """ + if password is None: + password = generate_password() + # TODO: send GPG encrypted mail with this information + if self.db_type == DB_TYPES.pgsql: + create_pgsql_user.delay(self.name, password).get() + elif self.db_type == DB_TYPES.mysql: + create_mysql_user.delay(self.name, password).get() + else: + raise ValueError('Unknown database type %d' % self.db_type) + + def set_password(self, password): + """ + Set an existing user's password. + + :param str password: new password for the database user + """ + if self.db_type == DB_TYPES.pgsql: + set_pgsql_userpassword.delay(self.name, password).get(timeout=5) + elif self.db_type == DB_TYPES.mysql: + set_mysql_userpassword.delay(self.name, password).get(timeout=5) + else: + raise ValueError('Unknown database type %d' % self.db_type) + + @transaction.atomic + def delete(self, *args, **kwargs): + """ + Delete the database user from the target database and the Django + database. + + :param args: positional arguments for + :py:meth:`django.db.models.Model.delete` + :param kwargs: keyword arguments for + :py:meth:`django.db.models.Model.delete` + + """ + for database in self.userdatabase_set.all(): + database.delete() + if self.db_type == DB_TYPES.pgsql: + delete_pgsql_user.delay(self.name).get(propagate=False, timeout=5) + elif self.db_type == DB_TYPES.mysql: + delete_mysql_user.delay(self.name).get(propagate=False, timeout=5) + else: + raise ValueError('Unknown database type %d' % self.db_type) + super(DatabaseUser, self).delete(*args, **kwargs) + + +class UserDatabaseManager(models.Manager): + """ + Default manager for :py:class:`userdbs.models.UserDatabase` instances. + + """ + + def _get_next_dbname(self, db_user): + """ + Get the next available database name for the given database user. + + :param db_user: :py:class:`userdbs.models.DatabaseUser` instance + :return: database name + :rtype: str + + """ + count = 1 + db_name_format = "{0}_{{0:02d}}".format(db_user.name) + # first db is named the same as the user + nextname = db_user.name + for name in self.values('db_name').filter(db_user=db_user).order_by( + 'db_name' + ): + if name['db_name'] == nextname: + count += 1 + nextname = db_name_format.format(count) + else: + break + return nextname + + @transaction.atomic + def create_userdatabase(self, db_user, db_name=None, commit=True): + """ + Creates a new user database. + + :param db_user: :py:class:`userdbs.models.DatabaseUser` instance + :param str db_name: database name + :param boolean commit: whether the database should be persisted + :return: :py:class:`userdbs.models.UserDatabase` instance + + """ + if db_name is None: + db_name = self._get_next_dbname(db_user) + database = UserDatabase(db_user=db_user, db_name=db_name) + if commit: + database.create_in_database() + database.save() + return database + + +@python_2_unicode_compatible +class UserDatabase(TimeStampedModel, models.Model): + # MySQL limits to 64, PostgreSQL to 63 characters + db_name = models.CharField( + _('database name'), max_length=63) + db_user = models.ForeignKey(DatabaseUser, verbose_name=_('database user')) + + objects = UserDatabaseManager() + + class Meta: + unique_together = ['db_name', 'db_user'] + verbose_name = _('user database') + verbose_name_plural = _('user specific database') + + def __str__(self): + return "%(db_name)s (%(db_user)s)" % { + 'db_name': self.db_name, + 'db_user': self.db_user, + } + + def create_in_database(self): + """ + Create this database (schema) in the target database. + + """ + # TODO: send GPG encrypted mail with this information + if self.db_user.db_type == DB_TYPES.pgsql: + create_pgsql_database.delay(self.db_name, self.db_user.name).get() + elif self.db_user.db_type == DB_TYPES.mysql: + create_mysql_database.delay(self.db_name, self.db_user.name).get() + else: + raise ValueError('Unknown database type %d' % self.db_type) + + @transaction.atomic + def delete(self, *args, **kwargs): + """ + Delete the database (schema) from the target database and the Django + database. + + :param args: positional arguments for + :py:meth:`django.db.models.Model.delete` + :param kwargs: keyword arguments for + :py:meth:`django.db.models.Model.delete` + + """ + if self.db_user.db_type == DB_TYPES.pgsql: + delete_pgsql_database.delay(self.db_name).get() + elif self.db_user.db_type == DB_TYPES.mysql: + delete_mysql_database.delay(self.db_name, self.db_user.name).get() + else: + raise ValueError('Unknown database type %d' % self.db_type) + super(UserDatabase, self).delete(*args, **kwargs) diff --git a/gnuviechadmin/userdbs/tests.py b/gnuviechadmin/userdbs/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/gnuviechadmin/userdbs/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/gnuviechadmin/userdbs/views.py b/gnuviechadmin/userdbs/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/gnuviechadmin/userdbs/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.