From 0df67e7154879aa786ae7fc1c8aa34712287d638 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Sat, 27 Dec 2014 22:44:27 +0100 Subject: [PATCH] document osusers code --- gnuviechadmin/osusers/__init__.py | 5 + gnuviechadmin/osusers/admin.py | 100 ++++++++++++++++- gnuviechadmin/osusers/apps.py | 17 +++ gnuviechadmin/osusers/models.py | 181 ++++++++++++++++++++++++++++-- gnuviechadmin/osusers/tasks.py | 154 +++++++++++++++++++++++-- 5 files changed, 430 insertions(+), 27 deletions(-) create mode 100644 gnuviechadmin/osusers/apps.py 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 1025038..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,7 +39,8 @@ 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( @@ -44,6 +60,9 @@ class UserCreationForm(forms.ModelForm): """ 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') @@ -55,6 +74,10 @@ 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'], @@ -65,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] @@ -83,6 +112,13 @@ class UserAdmin(admin.ModelAdmin): """ 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: @@ -94,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'] @@ -111,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..4ca9b7f --- /dev/null +++ b/gnuviechadmin/osusers/apps.py @@ -0,0 +1,17 @@ +""" +This module contains the :py:class:`django.apps.AppConfig` instance for the +:py:module:`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/models.py b/gnuviechadmin/osusers/models.py index 1b0d567..d5a65ef 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 @@ -30,7 +34,7 @@ from .tasks import ( ) -logger = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) CANNOT_USE_PRIMARY_GROUP_AS_ADDITIONAL = _( @@ -38,8 +42,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 +63,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 +86,73 @@ 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 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` + + """ delete_ldap_group_if_empty.delay(self.groupname).get() 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( @@ -107,6 +165,23 @@ class UserManager(models.Manager): 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: @@ -126,6 +201,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( @@ -147,6 +226,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: @@ -161,12 +249,24 @@ 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( + _LOGGER.info( "created user %(user)s with LDAP dn %(dn)s, home directory " "%(homedir)s and mail base directory %(maildir)s.", { 'user': self, 'dn': dn, @@ -176,6 +276,16 @@ class User(TimeStampedModel, models.Model): @transaction.atomic def delete(self, *args, **kwargs): + """ + 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` + + """ delete_file_mail_userdir.delay(self.username).get() delete_file_sftp_userdir.delay(self.username).get() for group in [ag.group for ag in self.additionalgroup_set.all()]: @@ -187,9 +297,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, @@ -203,6 +327,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( @@ -245,11 +374,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) @@ -258,21 +397,45 @@ 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): + """ + Delete the group assignment 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` + """ remove_ldap_user_from_group.delay( self.user.username, self.group.groupname).get() super(AdditionalGroup, self).delete(*args, **kwargs) - - def __str__(self): - return '{0} in {1}'.format(self.user, self.group) diff --git a/gnuviechadmin/osusers/tasks.py b/gnuviechadmin/osusers/tasks.py index 99c7b1c..ce250ab 100644 --- a/gnuviechadmin/osusers/tasks.py +++ b/gnuviechadmin/osusers/tasks.py @@ -1,3 +1,10 @@ +""" +This module defines task stubs for the tasks implemented on the `Celery`_ +workers. + +.. _Celery: http://www.celeryproject.org/ + +""" from __future__ import absolute_import from celery import shared_task @@ -5,59 +12,182 @@ 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. + + :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 + + """