models.py 18.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE.  If not, see <http://www.gnu.org/licenses/>.

18 19
from __future__ import absolute_import

20
import json
21
from hashlib import md5
22 23
from logging import getLogger

24
from datetime import timedelta
25
from django.conf import settings
26
from django.contrib.auth.models import User, Group, Permission
27
from django.contrib.auth.signals import user_logged_in
28
from django.core.exceptions import ObjectDoesNotExist
29
from django.core.urlresolvers import reverse
30
from django.db.models import (
31
    Model, ForeignKey, OneToOneField, CharField, IntegerField, TextField,
32
    DateTimeField, BooleanField
33
)
34
from django.db.models.signals import post_save, pre_delete, post_delete
35
from django.templatetags.static import static
36
from django.utils import timezone
37
from django.utils.html import escape
38
from django.utils.translation import ugettext_lazy as _
39
from django_sshkey.models import UserKey
40
from itertools import chain
41
from jsonfield import JSONField
42
from model_utils import Choices
43 44 45
from model_utils.fields import StatusField
from model_utils.models import TimeFramedModel, TimeStampedModel
from sizefield.models import FileSizeField
46

47
from acl.models import AclBase
48
from common.models import HumanReadableObject, create_readable, Encoder
49
from vm.models.instance import ACCESS_METHODS
50
from .store_api import Store, NoStoreException, NotOkException
51
from .validators import connect_command_template_validator
52

53 54
logger = getLogger(__name__)

Bach Dániel committed
55 56 57

def pwgen():
    return User.objects.make_random_password()
58

59

60
class Message(TimeStampedModel, TimeFramedModel):
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
    message = CharField(max_length=500, verbose_name=_('message'))
    effect = CharField(
        default='info', max_length=10, verbose_name=_('effect'),
        choices=(('success', _('success')), ('info', _('info')),
                 ('warning', _('warning')), ('danger', _('danger'))))
    enabled = BooleanField(default=False, verbose_name=_('enabled'))

    class Meta:
        ordering = ["id"]
        verbose_name = _('message')
        verbose_name_plural = _('messages')

    def __unicode__(self):
        return self.message

    def get_absolute_url(self):
77 78
        return reverse('dashboard.views.message-detail',
                       kwargs={'pk': self.pk})
79 80


81
class Favourite(Model):
82
    instance = ForeignKey("vm.Instance")
83
    user = ForeignKey(User)
84 85


86 87 88 89 90 91 92
class Notification(TimeStampedModel):
    STATUS = Choices(('new', _('new')),
                     ('delivered', _('delivered')),
                     ('read', _('read')))

    status = StatusField()
    to = ForeignKey(User)
93 94
    subject_data = JSONField(null=True, dump_kwargs={"cls": Encoder})
    message_data = JSONField(null=True, dump_kwargs={"cls": Encoder})
95
    valid_until = DateTimeField(null=True, default=None)
96 97 98 99 100

    class Meta:
        ordering = ['-created']

    @classmethod
101 102 103
    def send(cls, user, subject, template, context,
             valid_until=None, subject_context=None):
        hro = create_readable(template, user=user, **context)
104
        subject = create_readable(subject, **(subject_context or context))
105 106 107
        return cls.objects.create(to=user,
                                  subject_data=subject.to_dict(),
                                  message_data=hro.to_dict(),
108
                                  valid_until=valid_until)
109

110 111
    @property
    def subject(self):
112 113
        return HumanReadableObject.from_dict(
            self.escape_dict(self.subject_data))
114 115 116 117 118 119 120

    @subject.setter
    def subject(self, value):
        self.subject_data = None if value is None else value.to_dict()

    @property
    def message(self):
121 122 123 124 125 126 127 128
        return HumanReadableObject.from_dict(
            self.escape_dict(self.message_data))

    def escape_dict(self, data):
        for k, v in data['params'].items():
            if isinstance(v, basestring):
                data['params'][k] = escape(v)
        return data
129 130 131 132 133

    @message.setter
    def message(self, value):
        self.message_data = None if value is None else value.to_dict()

134 135 136 137 138 139 140 141 142 143 144
    @property
    def has_valid_renew_url(self):
        params = self.message_data['params']
        return ('token' in params and 'suspend' in params and
                self.modified > timezone.now() - timedelta(days=3))

    @property
    def renew_url(self):
        return (settings.DJANGO_URL.rstrip("/") +
                str(self.message_data['params'].get('token')))

145

146 147 148 149
class ConnectCommand(Model):
    user = ForeignKey(User, related_name='command_set')
    access_method = CharField(max_length=10, choices=ACCESS_METHODS,
                              verbose_name=_('access method'),
150
                              help_text=_('Type of the remote access method.'))
151
    name = CharField(max_length=128, verbose_name=_('name'), blank=False,
152
                     help_text=_("Name of your custom command."))
153
    template = CharField(blank=True, null=True, max_length=256,
154 155 156 157
                         verbose_name=_('command template'),
                         help_text=_('Template for connection command string. '
                                     'Available parameters are: '
                                     'username, password, '
158 159
                                     'host, port.'),
                         validators=[connect_command_template_validator])
160

161
    class Meta:
162
        ordering = ('id',)
163

164 165
    def __unicode__(self):
        return self.template
166 167


168 169 170 171 172 173 174 175 176
class Profile(Model):
    user = OneToOneField(User)
    preferred_language = CharField(verbose_name=_('preferred language'),
                                   choices=settings.LANGUAGES,
                                   max_length=32,
                                   default=settings.LANGUAGE_CODE, blank=False)
    org_id = CharField(  # may be populated from eduPersonOrgId field
        unique=True, blank=True, null=True, max_length=64,
        help_text=_('Unique identifier of the person, e.g. a student number.'))
177
    instance_limit = IntegerField(default=5)
178
    template_instance_limit = IntegerField(default=1)
179
    use_gravatar = BooleanField(
180
        verbose_name=_("Use Gravatar"), default=True,
181
        help_text=_("Whether to use email address as Gravatar profile image"))
182 183
    email_notifications = BooleanField(
        verbose_name=_("Email notifications"), default=True,
184
        help_text=_('Whether user wants to get digested email notifications.'))
185 186
    desktop_notifications = BooleanField(
        verbose_name=_("Desktop notifications"), default=False,
187 188
        help_text=_('Whether user wants to get desktop notification when an '
                    'activity has finished and the window is not in focus.'))
189 190 191 192 193
    smb_password = CharField(
        max_length=20,
        verbose_name=_('Samba password'),
        help_text=_(
            'Generated password for accessing store from '
Kálmán Viktor committed
194
            'virtual machines.'),
195 196
        default=pwgen,
    )
197
    disk_quota = FileSizeField(
198
        verbose_name=_('disk quota'),
199
        default=2048 * 1024 * 1024,
200
        help_text=_('Disk quota in mebibytes.'))
201 202 203 204
    two_factor_secret = CharField(
        verbose_name=_("two factor secret key"),
        max_length=32, null=True, blank=True,
    )
205

206
    def get_connect_commands(self, instance, use_ipv6=False):
207
        """ Generate connection command based on template."""
208 209 210
        single_command = instance.get_connect_command(use_ipv6)
        if single_command:  # can we even connect to that VM
            commands = self.user.command_set.filter(
211
                access_method=instance.access_method)
212
            if commands.count() < 1:
213
                return [{'id': 0, 'cmd': single_command}]
214
            else:
215 216 217
                return [{
                    'id': command.id,
                    'cmd': command.template % {
218
                        'port': instance.get_connect_port(use_ipv6=use_ipv6),
219
                        'host': instance.get_connect_host(use_ipv6=use_ipv6),
220 221
                        'password': instance.pw,
                        'username': 'cloud',
222
                    }} for command in commands]
223
        else:
224
            return []
225

226 227 228 229 230
    def notify(self, subject, template, context=None, valid_until=None,
               **kwargs):
        if context is not None:
            kwargs.update(context)
        return Notification.send(self.user, subject, template, kwargs,
231
                                 valid_until)
232

233
    def get_absolute_url(self):
Kálmán Viktor committed
234 235
        return reverse("dashboard.views.profile",
                       kwargs={'username': self.user.username})
236

237 238 239 240 241 242 243 244
    def get_avatar_url(self):
        if self.use_gravatar:
            gravatar_hash = md5(self.user.email).hexdigest()
            return ("https://secure.gravatar.com/avatar/%s"
                    "?s=200" % gravatar_hash)
        else:
            return static("dashboard/img/avatar.png")

245 246 247 248 249 250 251 252 253 254 255 256 257
    def get_display_name(self):
        if self.user.get_full_name():
            name = self.user.get_full_name()
        else:
            name = self.user.username

        if self.org_id:
            name = "%s (%s)" % (name, self.org_id)
        return name

    def __unicode__(self):
        return self.get_display_name()

258 259 260 261 262
    def save(self, *args, **kwargs):
        if self.org_id == "":
            self.org_id = None
        super(Profile, self).save(*args, **kwargs)

263
    class Meta:
264
        ordering = ('id',)
265 266 267 268
        permissions = (
            ('use_autocomplete', _('Can use autocomplete.')),
        )

Őry Máté committed
269

270 271 272 273 274 275
class FutureMember(Model):
    org_id = CharField(max_length=64, help_text=_(
        'Unique identifier of the person, e.g. a student number.'))
    group = ForeignKey(Group)

    class Meta:
276
        ordering = ('id',)
277 278 279 280 281
        unique_together = ('org_id', 'group')

    def __unicode__(self):
        return u"%s (%s)" % (self.org_id, self.group)

Őry Máté committed
282

283 284 285 286 287 288 289 290 291 292
class GroupProfile(AclBase):
    ACL_LEVELS = (
        ('operator', _('operator')),
        ('owner', _('owner')),
    )

    group = OneToOneField(Group)
    org_id = CharField(
        unique=True, blank=True, null=True, max_length=64,
        help_text=_('Unique identifier of the group at the organization.'))
293 294
    instance_limit = IntegerField(default=5)
    template_instance_limit = IntegerField(default=1)
295
    description = TextField(blank=True)
296 297 298 299
    disk_quota = FileSizeField(
        verbose_name=_('disk quota'),
        default=2048 * 1024 * 1024,
        help_text=_('Disk quota in mebibytes.'))
300

301
    class Meta:
302
        ordering = ('id',)
303

304 305 306
    def __unicode__(self):
        return self.group.name

307 308 309 310
    def save(self, *args, **kwargs):
        if not self.org_id:
            self.org_id = None
        super(GroupProfile, self).save(*args, **kwargs)
311 312 313 314 315 316 317 318

    @classmethod
    def search(cls, name):
        try:
            return cls.objects.get(org_id=name).group
        except cls.DoesNotExist:
            return Group.objects.get(name=name)

319
    def get_absolute_url(self):
320 321
        return reverse('dashboard.views.group-detail',
                       kwargs={'pk': self.group.pk})
322

323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341
    @classmethod
    def create_from_json(cls, owner, json_data):
        group = Group()
        try:
            data = json.loads(json_data)
            group.name = data["name"]
            group.save()

            profile = group.profile
            profile.set_user_level(owner, "owner")
            profile.description = data["desc"]
            profile.org_id = data["org_id"]
            profile.instance_limit = int(data["instance_limit"])
            profile.template_instance_limit = int(data["template_instance_limit"])
            profile.disk_quota = long(data["disk_quota"])
            profile.save()

            for org_id in data["users"]:
                try:
342 343 344
                    if org_id is not None:
                        user = Profile.objects.get(org_id=org_id).user
                        user.groups.add(group)
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359
                except ObjectDoesNotExist:
                    future_member = FutureMember(org_id=org_id, group=group)
                    future_member.save()

            for permission in data["permissions"]:
                group.permissions.add(
                    Permission.objects.get_by_natural_key(*permission)
                )

            return group.profile
        except (KeyError, ValueError, TypeError):
            if group.id is not None:
                group.delete()
            logger.error("Invalid group JSON")

360 361 362 363 364 365 366 367
    def convert_to_json(self):
        json_group = {
            "name": self.group.name,
            "desc": self.description,
            "org_id": self.org_id,
            "instance_limit": self.instance_limit,
            "template_instance_limit": self.template_instance_limit,
            "disk_quota": self.disk_quota,
368 369 370
            "users":
                [user.profile.org_id for user in self.group.user_set.all()] +
                [user.org_id for user in self.group.futuremember_set.all()],
371 372 373 374 375 376 377 378
            "permissions": [
                permission.natural_key()
                for permission in self.group.permissions.all()
            ]
        }

        return json.dumps(json_group)

379 380

def get_or_create_profile(self):
381
    obj, created = GroupProfile.objects.get_or_create(group_id=self.pk)
382 383
    return obj

384

385 386 387
Group.profile = property(get_or_create_profile)


388
def create_profile(user):
389 390
    if not user.pk:
        return False
391
    profile, created = Profile.objects.get_or_create(user=user)
392

Őry Máté committed
393
    try:
394
        store = Store(user)
395 396 397 398
        quotas = [profile.disk_quota]
        quotas += [group.profile.disk_quota for group in user.groups.all()]
        max_quota = max(quotas)
        store.create_user(profile.smb_password, None, max_quota)
Őry Máté committed
399 400
    except:
        logger.exception("Can't create user %s", unicode(user))
401 402
    return created

403 404 405 406

def create_profile_hook(sender, user, request, **kwargs):
    return create_profile(user)

407

408
user_logged_in.connect(create_profile_hook)
409

410
if hasattr(settings, 'SAML_ORG_ID_ATTRIBUTE'):
411
    logger.debug("Register save_org_id to djangosaml2 pre_user_save")
412 413
    from djangosaml2.signals import pre_user_save

414

Czémán Arnold committed
415 416
    def save_org_id(sender, instance, attributes, **kwargs):
        logger.debug("save_org_id called by %s", instance.username)
417
        atr = settings.SAML_ORG_ID_ATTRIBUTE
418
        try:
419
            value = attributes[atr][0].upper()
420 421 422 423
        except Exception as e:
            value = None
            logger.info("save_org_id couldn't find attribute. %s", unicode(e))

Czémán Arnold committed
424 425 426
        if instance.pk is None:
            instance.save()
            logger.debug("save_org_id saved user %s", unicode(instance))
427

Czémán Arnold committed
428
        profile, created = Profile.objects.get_or_create(user=instance)
429
        if created or profile.org_id != value:
430
            logger.info("org_id of %s added to user %s's profile",
Czémán Arnold committed
431
                        value, instance.username)
432 433
            profile.org_id = value
            profile.save()
434 435
        else:
            logger.debug("org_id of %s already added to user %s's profile",
Czémán Arnold committed
436
                         value, instance.username)
437
        memberatrs = getattr(settings, 'SAML_GROUP_ATTRIBUTES', [])
438 439
        for group in chain(*[attributes[i]
                             for i in memberatrs if i in attributes]):
440 441 442 443 444 445 446
            try:
                g = GroupProfile.search(group)
            except Group.DoesNotExist:
                logger.debug('cant find membergroup %s', group)
            else:
                logger.debug('could find membergroup %s (%s)',
                             group, unicode(g))
Czémán Arnold committed
447
                g.user_set.add(instance)
448

449
        for i in FutureMember.objects.filter(org_id__iexact=value):
Czémán Arnold committed
450
            i.group.user_set.add(instance)
451 452
            i.delete()

453
        owneratrs = getattr(settings, 'SAML_GROUP_OWNER_ATTRIBUTES', [])
454 455
        for group in chain(*[attributes[i]
                             for i in owneratrs if i in attributes]):
456 457 458 459 460 461 462
            try:
                g = GroupProfile.search(group)
            except Group.DoesNotExist:
                logger.debug('cant find ownergroup %s', group)
            else:
                logger.debug('could find ownergroup %s (%s)',
                             group, unicode(g))
Czémán Arnold committed
463
                g.profile.set_level(instance, 'owner')
464 465

        return False  # User did not change
466

467

468 469
    pre_user_save.connect(save_org_id)

470

471 472 473
def update_store_profile(sender, **kwargs):
    profile = kwargs.get('instance')
    keys = [i.key for i in profile.user.userkey_set.all()]
Guba Sándor committed
474 475 476 477 478 479
    try:
        s = Store(profile.user)
        s.create_user(profile.smb_password, keys,
                      profile.disk_quota)
    except NoStoreException:
        logger.debug("Store is not available.")
480
    except NotOkException:
481
        logger.critical("Store is not accepting connections.")
Guba Sándor committed
482

483 484 485 486 487 488

post_save.connect(update_store_profile, sender=Profile)


def update_store_keys(sender, **kwargs):
    userkey = kwargs.get('instance')
Guba Sándor committed
489
    try:
Guba Sándor committed
490 491 492 493 494 495 496 497 498 499 500
        profile = userkey.user.profile
    except ObjectDoesNotExist:
        pass  # If there is no profile the user is deleted
    else:
        keys = [i.key for i in profile.user.userkey_set.all()]
        try:
            s = Store(userkey.user)
            s.create_user(profile.smb_password, keys,
                          profile.disk_quota)
        except NoStoreException:
            logger.debug("Store is not available.")
501 502
        except NotOkException:
            logger.critical("Store is not accepting connections.")
503 504 505 506 507 508


post_save.connect(update_store_keys, sender=UserKey)
post_delete.connect(update_store_keys, sender=UserKey)


509 510 511 512 513
def add_ssh_keys(sender, **kwargs):
    from vm.models import Instance

    userkey = kwargs.get('instance')
    instances = Instance.get_objects_with_level(
514 515
        'user', userkey.user, disregard_superuser=True
    ).filter(status='RUNNING')
516 517
    for i in instances:
        logger.info('called add_keys(%s, %s)', i, userkey)
518 519 520 521
        try:
            i.install_keys(user=userkey.user, keys=[userkey.key])
        except Instance.NoAgentError:
            logger.info("%s has no agent running", i)
522 523 524 525 526 527 528


def del_ssh_keys(sender, **kwargs):
    from vm.models import Instance

    userkey = kwargs.get('instance')
    instances = Instance.get_objects_with_level(
529 530
        'user', userkey.user, disregard_superuser=True
    ).filter(status='RUNNING')
531 532
    for i in instances:
        logger.info('called del_keys(%s, %s)', i, userkey)
533 534 535 536
        try:
            i.remove_keys(user=userkey.user, keys=[userkey.key])
        except Instance.NoAgentError:
            logger.info("%s has no agent running", i)
537 538 539 540


post_save.connect(add_ssh_keys, sender=UserKey)
pre_delete.connect(del_ssh_keys, sender=UserKey)