]> git.ipfire.org Git - ipfire.org.git/commitdiff
people: Add page to edit user accounts
authorMichael Tremer <michael.tremer@ipfire.org>
Tue, 16 Oct 2018 23:36:43 +0000 (00:36 +0100)
committerMichael Tremer <michael.tremer@ipfire.org>
Tue, 16 Oct 2018 23:36:43 +0000 (00:36 +0100)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
Makefile.am
src/backend/accounts.py
src/backend/util.py
src/templates/people/user-edit.html [new file with mode: 0644]
src/templates/people/user.html
src/web/__init__.py
src/web/people.py

index 739ca836c8113c05dd33e4e78f7a0e720b29f712..679d340f89ebb1ed284c68472b208b27e8135468 100644 (file)
@@ -158,6 +158,7 @@ templates_people_DATA = \
        src/templates/people/registrations.html \
        src/templates/people/search.html \
        src/templates/people/user.html \
+       src/templates/people/user-edit.html \
        src/templates/people/users.html
 
 templates_peopledir = $(templatesdir)/people
index 6547ec4b5d84468c10b1fb429d0a0289cb9d391f..aa8b5024f9e3da748077f67b97bc97661152519f 100644 (file)
@@ -4,10 +4,13 @@
 import PIL
 import io
 import ldap
+import ldap.modlist
 import logging
+import phonenumbers
 import urllib.parse
 import urllib.request
 
+from . import util
 from .decorators import *
 from .misc import Object
 
@@ -152,7 +155,7 @@ class Account(Object):
                Object.__init__(self, backend)
                self.dn = dn
 
-               self.__attrs = attrs or {}
+               self.attributes = attrs or {}
 
        def __str__(self):
                return self.name
@@ -172,28 +175,74 @@ class Account(Object):
        def ldap(self):
                return self.accounts.ldap
 
-       @property
-       def attributes(self):
-               return self.__attrs
+       def _exists(self, key):
+               try:
+                       self.attributes[key]
+               except KeyError:
+                       return False
 
-       def _get_first_attribute(self, attr, default=None):
-               if attr not in self.attributes:
-                       return default
+               return True
 
-               res = self.attributes.get(attr, [])
-               if res:
-                       return res[0]
+       def _get(self, key):
+               for value in self.attributes.get(key, []):
+                       yield value
 
-       def get(self, key):
-               try:
-                       attribute = self.attributes[key]
-               except KeyError:
-                       raise AttributeError(key)
+       def _get_bytes(self, key, default=None):
+               for value in self._get(key):
+                       return value
+
+               return default
+
+       def _get_strings(self, key):
+               for value in self._get(key):
+                       yield value.decode()
+
+       def _get_string(self, key, default=None):
+               for value in self._get_strings(key):
+                       return value
+
+               return default
+
+       def _get_phone_numbers(self, key):
+               for value in self._get_strings(key):
+                       yield phonenumbers.parse(value, None)
+
+       def _modify(self, modlist):
+               logging.debug("Modifying %s: %s" % (self.dn, modlist))
+
+               # Run modify operation
+               self.ldap.modify_s(self.dn, modlist)
+
+       def _set(self, key, values):
+               current = self._get(key)
+
+               # Don't do anything if nothing has changed
+               if list(current) == values:
+                       return
+
+               # Remove all old values and add all new ones
+               modlist = []
 
-               if len(attribute) == 1:
-                       return attribute[0]
+               if self._exists(key):
+                       modlist.append((ldap.MOD_DELETE, key, None))
 
-               return attribute
+               # Add new values
+               modlist.append((ldap.MOD_ADD, key, values))
+
+               # Run modify operation
+               self._modify(modlist)
+
+               # Update cache
+               self.attributes.update({ key : values })
+
+       def _set_bytes(self, key, values):
+               return self._set(key, values)
+
+       def _set_strings(self, key, values):
+               return self._set(key, [e.encode() for e in values])
+
+       def _set_string(self, key, value):
+               return self._set_strings(key, [value,])
 
        def check_password(self, password):
                """
@@ -220,21 +269,54 @@ class Account(Object):
                return "sipUser" in self.classes or "sipRoutingObject" in self.classes \
                        or self.telephone_numbers or self.address
 
+       def can_be_managed_by(self, account):
+               """
+                       Returns True if account is allowed to manage this account
+               """
+               # Admins can manage all accounts
+               if account.is_admin():
+                       return True
+
+               # Users can manage themselves
+               return self == account
+
        @property
        def classes(self):
-               return (x.decode() for x in self.attributes.get("objectClass", []))
+               return self._get_strings("objectClass")
 
        @property
        def uid(self):
-               return self._get_first_attribute("uid").decode()
+               return self._get_string("uid")
 
        @property
        def name(self):
-               return self._get_first_attribute("cn").decode()
+               return self._get_string("cn")
 
-       @property
-       def first_name(self):
-               return self._get_first_attribute("givenName").decode()
+       # First Name
+
+       def get_first_name(self):
+               return self._get_string("givenName")
+
+       def set_first_name(self, first_name):
+               self._set_string("givenName", first_name)
+
+               # Update Common Name
+               self._set_string("cn", "%s %s" % (first_name, self.last_name))
+
+       first_name = property(get_first_name, set_first_name)
+
+       # Last Name
+
+       def get_last_name(self):
+               return self._get_string("sn")
+
+       def set_last_name(self, last_name):
+               self._set_string("sn", last_name)
+
+               # Update Common Name
+               self._set_string("cn", "%s %s" % (self.first_name, last_name))
+
+       last_name = property(get_last_name, set_last_name)
 
        @lazy_property
        def groups(self):
@@ -250,12 +332,22 @@ class Account(Object):
 
                return groups
 
-       @property
-       def address(self):
-               address = self._get_first_attribute("homePostalAddress", "".encode()).decode()
-               address = address.replace(", ", "\n")
+       # Address
+
+       def get_address(self):
+               address = self._get_string("homePostalAddress")
+
+               if address:
+                       return (line.strip() for line in address.split(","))
 
-               return address
+               return []
+
+       def set_address(self, address):
+               data = ", ".join(address.splitlines())
+
+               self._set_bytes("homePostalAddress", data.encode())
+
+       address = property(get_address, set_address)
 
        @property
        def email(self):
@@ -275,32 +367,84 @@ class Account(Object):
                # If everything else fails, we will go with the UID
                return "%s@ipfire.org" % self.uid
 
+       # Mail Routing Address
+
+       def get_mail_routing_address(self):
+               return self._get_string("mailRoutingAddress", None)
+
+       def set_mail_routing_address(self, address):
+               self._set_string("mailRoutingAddress", address)
+
+       mail_routing_address = property(get_mail_routing_address, set_mail_routing_address)
+
        @property
        def sip_id(self):
                if "sipUser" in self.classes:
-                       return self._get_first_attribute("sipAuthenticationUser").decode()
+                       return self._get_string("sipAuthenticationUser")
 
                if "sipRoutingObject" in self.classes:
-                       return self._get_first_attribute("sipLocalAddress").decode()
+                       return self._get_string("sipLocalAddress")
 
        @property
        def sip_password(self):
-               return self._get_first_attribute("sipPassword").decode()
+               return self._get_string("sipPassword")
+
+       @staticmethod
+       def _generate_sip_password():
+               return util.random_string(8)
 
        @property
        def sip_url(self):
                return "%s@ipfire.org" % self.sip_id
 
        def uses_sip_forwarding(self):
-               if self.sip_routing_url:
+               if self.sip_routing_address:
                        return True
 
                return False
 
-       @property
-       def sip_routing_url(self):
+       # SIP Routing
+
+       def get_sip_routing_address(self):
                if "sipRoutingObject" in self.classes:
-                       return self._get_first_attribute("sipRoutingAddress").decode()
+                       return self._get_string("sipRoutingAddress")
+
+       def set_sip_routing_address(self, address):
+               if not address:
+                       address = None
+
+               # Don't do anything if nothing has changed
+               if self.get_sip_routing_address() == address:
+                       return
+
+               if address:
+                       modlist = [
+                               # This is no longer a SIP user any more
+                               (ldap.MOD_DELETE, "objectClass", b"sipUser"),
+                               (ldap.MOD_DELETE, "sipAuthenticationUser", None),
+                               (ldap.MOD_DELETE, "sipPassword", None),
+
+                               (ldap.MOD_ADD, "objectClass", b"sipRoutingObject"),
+                               (ldap.MOD_ADD, "sipLocalAddress", self.sip_id.encode()),
+                               (ldap.MOD_ADD, "sipRoutingAddress", address.encode()),
+                       ]
+               else:
+                       modlist = [
+                               (ldap.MOD_DELETE, "objectClass", b"sipRoutingObject"),
+                               (ldap.MOD_DELETE, "sipLocalAddress", None),
+                               (ldap.MOD_DELETE, "sipRoutingAddress", None),
+
+                               (ldap.MOD_ADD, "objectClass", b"sipUser"),
+                               (ldap.MOD_ADD, "sipAuthenticationUser", self.sip_id.encode()),
+                               (ldap.MOD_ADD, "sipPassword", self._generate_sip_password().encode()),
+                       ]
+
+               # Run modification
+               self._modify(modlist)
+
+               # XXX Cache is invalid here
+
+       sip_routing_address = property(get_sip_routing_address, set_sip_routing_address)
 
        @lazy_property
        def sip_registrations(self):
@@ -316,26 +460,46 @@ class Account(Object):
        def get_cdr(self, date=None, limit=None):
                return self.backend.talk.freeswitch.get_cdr_by_account(self, date=date, limit=limit)
 
-       @property
-       def telephone_numbers(self):
-               return self._telephone_numbers + self.mobile_telephone_numbers \
-                       + self.home_telephone_numbers
+       # Phone Numbers
 
-       @property
-       def _telephone_numbers(self):
-               return list((x.decode() for x in self.attributes.get("telephoneNumber") or []))
+       def get_phone_numbers(self):
+               ret = []
 
-       @property
-       def home_telephone_numbers(self):
-               return list((x.decode() for x in self.attributes.get("homePhone") or []))
+               for field in ("telephoneNumber", "homePhone", "mobile"):
+                       for number in self._get_phone_numbers(field):
+                               ret.append(number)
 
-       @property
-       def mobile_telephone_numbers(self):
-               return list((x.decode() for x in self.attributes.get("mobile") or []))
+               return ret
+
+       def set_phone_numbers(self, phone_numbers):
+               # Sort phone numbers by landline and mobile
+               _landline_numbers = []
+               _mobile_numbers = []
+
+               for number in phone_numbers:
+                       try:
+                               number = phonenumbers.parse(number, None)
+                       except phonenumbers.phonenumberutil.NumberParseException:
+                               continue
+
+                       # Convert to string (in E.164 format)
+                       s = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
+
+                       # Separate mobile numbers
+                       if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE:
+                               _mobile_numbers.append(s)
+                       else:
+                               _landline_numbers.append(s)
+
+               # Save
+               self._set_strings("telephoneNumber", _landline_numbers)
+               self._set_strings("mobile", _mobile_numbers)
+
+       phone_numbers = property(get_phone_numbers, set_phone_numbers)
 
        @property
        def _all_telephone_numbers(self):
-               return [ self.sip_id, ] + self.telephone_numbers
+               return [ self.sip_id, ] + list(self.phone_numbers)
 
        def avatar_url(self, size=None):
                if self.backend.debug:
@@ -351,7 +515,7 @@ class Account(Object):
                return url
 
        def get_avatar(self, size=None):
-               avatar = self._get_first_attribute("jpegPhoto")
+               avatar = self._get_bytes("jpegPhoto")
                if not avatar:
                        return
 
index bcd8ca56111de46b9daaf4f2cb1ae22cac79b57e..e64039eecf780db380a278af76495f1f422be7f5 100644 (file)
@@ -1,5 +1,8 @@
 #!/usr/bin/python
 
+import random
+import string
+
 def format_size(s):
        units = ("B", "k", "M", "G", "T")
 
@@ -24,3 +27,10 @@ def format_time(s, shorter=True):
                return _("%(min)d min") % { "min" : min }
 
        return _("%(hrs)d:%(min)02d hrs") % {"hrs" : hrs, "min" : min}
+
+def random_string(length=8):
+       input_chars = string.ascii_letters + string.digits
+
+       r = (random.choice(input_chars) for i in range(length))
+
+       return "".join(r)
diff --git a/src/templates/people/user-edit.html b/src/templates/people/user-edit.html
new file mode 100644 (file)
index 0000000..24b4521
--- /dev/null
@@ -0,0 +1,87 @@
+{% extends "base.html" %}
+
+{% block title %}{{ _("Edit %s") % account }}{% end block %}
+
+{% block main %}
+       <div class="row justify-content-center">
+               <div class="col-6">
+                       <h4 class="mb-4">{{ _("Edit %s") % account }}</h4>
+
+                       <form method="POST" action="">
+                               {% raw xsrf_form_html() %}
+
+                               <div class="form-row mb-3">
+                                       <div class="col">
+                                               <label>{{ _("First Name") }}</label>
+
+                                               <input type="text" class="form-control" name="first_name"
+                                                       placeholder="{{ _("First Name") }}" value="{{ account.first_name }}" required>
+                                       </div>
+
+                                       <div class="col">
+                                               <label>{{ _("Last Name") }}</label>
+
+                                               <input type="text" class="form-control" name="last_name"
+                                                       placeholder="{{ _("Last Name") }}" value="{{ account.last_name }}" required>
+                                       </div>
+                               </div>
+
+                               <div class="form-group">
+                                       <label>{{ _("Address") }}</label>
+
+                                       <textarea type="text" class="form-control" name="address" rows="5" required
+                                               placeholder="{{ _("Address") }}">{{ "\n".join(account.address) }}</textarea>
+
+                                       <small class="form-text text-muted">
+                                               {{ _("Enter your full address including your country") }}
+                                       </small>
+                               </div>
+
+                               <fieldset>
+                                       <legend>{{ _("Email") }}</legend>
+       
+                                       <div class="form-group">
+                                               <label>{{ _("Forward Emails") }}</label>
+
+                                               <input type="mail" class="form-control" name="mail_routing_address"
+                                                       placeholder="{{ _("Email Address") }}" value="{{ account.mail_routing_address or "" }}">
+
+                                               <small class="form-text text-muted">
+                                                       {{ _("All emails will be forwarded to this email address") }}
+                                               </small>
+                                       </div>
+                               </fieldset>
+
+                               <fieldset>
+                                       <legend>{{ _("Telephone") }}</legend>
+
+                                       <div class="form-group">
+                                               <label>{{ _("Phone Numbers") }}</label>
+
+                                               <textarea type="text" class="form-control" name="phone_numbers" rows="5"
+                                                       placeholder="{{ _("Phone Numbers") }}">{{ "\n".join((format_phone_number_to_e164(n) for n in account.phone_numbers)) }}</textarea>
+
+                                               <small class="form-text text-muted">
+                                                       {{ _("Enter your landline and mobile phone numbers") }}
+                                               </small>
+                                       </div>
+
+                                       <div class="form-group">
+                                               <label>{{ _("Forward Calls") }}</label>
+
+                                               <input type="text" class="form-control" name="sip_routing_address"
+                                                       placeholder="{{ _("SIP URI or Phone Number") }}" value="{{ account.sip_routing_address or "" }}">
+
+                                               <small class="form-text text-muted">
+                                                       {{ _("All calls will be forwarded to this phone number or SIP URI") }}
+                                               </small>
+                                       </div>
+                               </fieldset>
+
+                               <input class="btn btn-primary btn-block" type="submit" value="{{ _("Save") }}">
+                       </form>
+               </div>
+       </div>
+{% end block %}
+
+{% block right %}{% end block %}
index a7f3df260a67fc23a308e7cb847cc6d6a86f0564..b9b8b635effe789dee41c47c3ad92fdbf9db396a 100644 (file)
@@ -3,6 +3,8 @@
 {% block title %}{{ account }}{% end block %}
 
 {% block main %}
+       {% import phonenumbers %}
+
        <div class="row justify-content-center">
                <div class="col col-md-6 col-lg-4 mb-5">
                        <img class="img-fluid rounded-circle my-5" src="{{ account.avatar_url(512) }}" alt="{{ account }}" />
                                                                <address>
                                                                        <strong>{{ account.name }}</strong>
                                                                        <br>
-                                                                       {% for line in account.address.splitlines() %}
+                                                                       {% for line in account.address %}
                                                                                {{ line }}<br>
                                                                        {% end %}
                                                                </address>
                                                        </div>
                                                {% end %}
                
-                                               {% if account.telephone_numbers %}
+                                               {% if account.phone_numbers %}
                                                        <div class="col-md-6 mt-5">
-                                                               <h6>{{ _("Telephone Numbers") }}</h6>
+                                                               <h6>{{ _("Phone Numbers") }}</h6>
                        
                                                                <ul class="list-unstyled">
-                                                                       {% for number in account.telephone_numbers %}
+                                                                       {% for number in account.phone_numbers %}
                                                                                <li>
-                                                                                       {% if number in account.mobile_telephone_numbers %}
-                                                                                               <span class="fa fa-mobile" title="{{ _("Mobile Phone") }}"></span>
-                                                                                       {% elif number in account.home_telephone_numbers %}
-                                                                                               <span class="fa fa-home" title="{{ _("Home Telephone") }}"></span>
+                                                                                       {% if phonenumbers.number_type(number) == phonenumbers.PhoneNumberType.MOBILE %}
+                                                                                               <span class="fa fa-mobile" title="{{ _("Mobile") }}"></span>
                                                                                        {% else %}
                                                                                                <span class="fa fa-phone"></span>
                                                                                        {% end %}
-                                                                                       <a href="tel:{{ number }}">{{ number }}</a>
+
+                                                                                       <a href="tel:{{ format_phone_number_to_e164(number) }}"
+                                                                                               title="{{ format_phone_number_location(number) }}">{{ format_phone_number(number) }}</a>
                                                                                </li>
                                                                        {% end %}
                                                                </ul>
                                        {% if (current_user == account or current_user.is_admin()) and account.uses_sip_forwarding() %}
                                                <p class="text-muted mb-0">
                                                        {{ _("All calls are forwarded to") }}
-                                                       <a href="sip:{{ account.sip_routing_url }}">{{ account.sip_routing_url }}</a>
+                                                       <a href="sip:{{ account.sip_routing_address }}">{{ account.sip_routing_address }}</a>
                                                </p>
                                        {% end %}
+
+                                       <div class="btn-toolbar">
+                                               {% if account.can_be_managed_by(current_user) %}
+                                                       <a class="btn btn-warning btn-sm btn-block" href="/users/{{ account.uid }}/edit">
+                                                               <span class="fas fa-edit mr-2"></span> {{ _("Edit") }}
+                                                       </a>
+                                               {% end %}
+                                       </div>
                                </div>
                        </div>
                </div>
index c6af8296079d26bcf80ce8d238b2b3e208bca535..0162d49a7ba3a1497b3a9484ced4cab41afd0944 100644 (file)
@@ -4,6 +4,7 @@ import logging
 import itertools
 import os.path
 import phonenumbers
+import phonenumbers.geocoder
 import tornado.locale
 import tornado.options
 import tornado.web
@@ -41,9 +42,11 @@ class Application(tornado.web.Application):
 
                        # UI Modules
                        "ui_methods" : {
-                               "format_month_name"   : self.format_month_name,
-                               "format_phone_number" : self.format_phone_number,
-                               "grouper"             : grouper,
+                               "format_month_name"            : self.format_month_name,
+                               "format_phone_number"          : self.format_phone_number,
+                               "format_phone_number_to_e164"  : self.format_phone_number_to_e164,
+                               "format_phone_number_location" : self.format_phone_number_location,
+                               "grouper"                      : grouper,
                        },
                        "ui_modules" : {
                                # Blog
@@ -252,6 +255,7 @@ class Application(tornado.web.Application):
                        (r"/users/(\w+)", people.UserHandler),
                        (r"/users/(\w+)\.jpg", people.AvatarHandler),
                        (r"/users/(\w+)/calls(?:/(\d{4}-\d{2}-\d{2}))?", people.CallsHandler),
+                       (r"/users/(\w+)/edit", people.UserEditHandler),
                        (r"/users/(\w+)/registrations", people.RegistrationsHandler),
                ]  + authentication_handlers)
 
@@ -292,14 +296,26 @@ class Application(tornado.web.Application):
 
                return month
 
-       def format_phone_number(self, handler, s):
-               try:
-                       number = phonenumbers.parse(s, None)
-               except phonenumbers.phonenumberutil.NumberParseException:
-                       return s
+       def format_phone_number(self, handler, number):
+               if not isinstance(number, phonenumbers.PhoneNumber):
+                       try:
+                               number = phonenumbers.parse(s, None)
+                       except phonenumbers.phonenumberutil.NumberParseException:
+                               return number
 
                return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.INTERNATIONAL)
 
+       def format_phone_number_to_e164(self, handler, number):
+               return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
+
+       def format_phone_number_location(self, handler, number):
+               s = [
+                       phonenumbers.geocoder.description_for_number(number, handler.locale.code),
+                       phonenumbers.region_code_for_number(number),
+               ]
+
+               return ", ".join((e for e in s if e))
+
 
 def grouper(handler, iterator, n):
        """
index 0ad72de6369026b89f33ca14b675bf1926ff9f18..bcaae6f382d9e817128c23feb441c1034340453c 100644 (file)
@@ -1,6 +1,7 @@
 #!/usr/bin/python
 
 import datetime
+import ldap
 import logging
 import tornado.web
 
@@ -120,6 +121,48 @@ class UserHandler(base.BaseHandler):
                self.render("people/user.html", account=account)
 
 
+class UserEditHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       def get(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_managed_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
+
+               self.render("people/user-edit.html", account=account)
+
+       @tornado.web.authenticated
+       def post(self, uid):
+               account = self.backend.accounts.get_by_uid(uid)
+               if not account:
+                       raise tornado.web.HTTPError(404, "Could not find account %s" % uid)
+
+               # Check for permissions
+               if not account.can_be_managed_by(self.current_user):
+                       raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account))
+
+               # Unfortunately this cannot be wrapped into a transaction
+               try:
+                       account.first_name = self.get_argument("first_name")
+                       account.last_name  = self.get_argument("last_name")
+                       account.address    = self.get_argument("address")
+
+                       # Email
+                       account.mail_routing_address = self.get_argument("mail_routing_address", None)
+
+                       # Telephone
+                       account.phone_numbers = self.get_argument("phone_numbers", "").splitlines()
+                       account.sip_routing_address = self.get_argument("sip_routing_address", None)
+               except ldap.STRONG_AUTH_REQUIRED as e:
+                       raise tornado.web.HTTPError(403, "%s" % e) from e
+
+               # Redirect back to user page
+               self.redirect("/users/%s" % account.uid)
+
+
 class AccountsListModule(ui_modules.UIModule):
        def render(self, accounts=None):
                if accounts is None: