models.py 16.1 KB
Newer Older
Valentin Samir's avatar
Valentin Samir committed
1
# ⁻*- coding: utf-8 -*-
Valentin Samir's avatar
Valentin Samir committed
2 3 4 5 6 7 8 9 10 11
# This program 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 version 3 for
# more details.
#
# You should have received a copy of the GNU General Public License version 3
# along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# (c) 2015 Valentin Samir
Valentin Samir's avatar
Valentin Samir committed
12
"""models for the app"""
13
from .default_settings import settings
14

Valentin Samir's avatar
Valentin Samir committed
15
from django.db import models
16
from django.db.models import Q
Valentin Samir's avatar
Valentin Samir committed
17
from django.contrib import messages
18
from django.utils.translation import ugettext_lazy as _
19
from django.utils import timezone
20
from picklefield.fields import PickledObjectField
Valentin Samir's avatar
Valentin Samir committed
21 22 23

import re
import os
24
import sys
25
from importlib import import_module
26
from datetime import timedelta
Valentin Samir's avatar
Valentin Samir committed
27 28
from concurrent.futures import ThreadPoolExecutor
from requests_futures.sessions import FuturesSession
Valentin Samir's avatar
Valentin Samir committed
29

Valentin Samir's avatar
Valentin Samir committed
30
import cas_server.utils as utils
Valentin Samir's avatar
Valentin Samir committed
31

32 33
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore

Valentin Samir's avatar
PEP8  
Valentin Samir committed
34

Valentin Samir's avatar
Valentin Samir committed
35
class User(models.Model):
Valentin Samir's avatar
Valentin Samir committed
36
    """A user logged into the CAS"""
Valentin Samir's avatar
Valentin Samir committed
37
    class Meta:
38 39
        unique_together = ("username", "session_key")
    session_key = models.CharField(max_length=40, blank=True, null=True)
Valentin Samir's avatar
Valentin Samir committed
40
    username = models.CharField(max_length=30)
Valentin Samir's avatar
Valentin Samir committed
41
    date = models.DateTimeField(auto_now=True)
Valentin Samir's avatar
Valentin Samir committed
42

Valentin Samir's avatar
Valentin Samir committed
43 44
    @classmethod
    def clean_old_entries(cls):
45 46 47
        users = cls.objects.filter(
            date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
        )
Valentin Samir's avatar
Valentin Samir committed
48 49 50 51
        for user in users:
            user.logout()
        users.delete()

52 53 54 55 56 57 58
    @classmethod
    def clean_deleted_sessions(cls):
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

59 60 61 62 63
    @property
    def attributs(self):
        """return a fresh dict for the user attributs"""
        return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()

Valentin Samir's avatar
Valentin Samir committed
64
    def __unicode__(self):
Valentin Samir's avatar
oops  
Valentin Samir committed
65
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
66

Valentin Samir's avatar
Valentin Samir committed
67
    def logout(self, request=None):
Valentin Samir's avatar
Valentin Samir committed
68
        """Sending SLO request to all services the user logged in"""
Valentin Samir's avatar
Valentin Samir committed
69
        async_list = []
70 71 72
        session = FuturesSession(
            executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
        )
73 74
        # first invalidate all Tickets
        ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
75
        for ticket_class in ticket_classes:
76 77
            queryset = ticket_class.objects.filter(user=self)
            for ticket in queryset:
78
                ticket.logout(request, session, async_list)
79
            queryset.delete()
Valentin Samir's avatar
Valentin Samir committed
80
        for future in async_list:
81 82 83 84
            if future:
                try:
                    future.result()
                except Exception as error:
Valentin Samir's avatar
Valentin Samir committed
85 86 87 88 89 90 91
                    if request is not None:
                        error = utils.unpack_nested_exception(error)
                        messages.add_message(
                            request,
                            messages.WARNING,
                            _(u'Error during service logout %s') % error
                        )
Valentin Samir's avatar
Valentin Samir committed
92 93 94 95 96 97 98 99 100 101 102 103 104

    def get_ticket(self, ticket_class, service, service_pattern, renew):
        """
           Generate a ticket using `ticket_class` for the service
           `service` matching `service_pattern` and asking or not for
           authentication renewal with `renew
        """
        attributs = dict(
            (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all()
        )
        replacements = dict(
            (a.name, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
        )
Valentin Samir's avatar
Valentin Samir committed
105
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
106
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
107
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
108 109
                if key in replacements:
                    value = re.sub(replacements[key][0], replacements[key][1], value)
Valentin Samir's avatar
Valentin Samir committed
110
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
111 112 113 114 115
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
116 117
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
118
        )
Valentin Samir's avatar
Valentin Samir committed
119
        ticket.save()
120
        self.save()
Valentin Samir's avatar
Valentin Samir committed
121 122 123
        return ticket

    def get_service_url(self, service, service_pattern, renew):
Valentin Samir's avatar
Valentin Samir committed
124 125
        """Return the url to which the user must be redirected to
        after a Service Ticket has been generated"""
Valentin Samir's avatar
Valentin Samir committed
126
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
127
        url = utils.update_url(service, {'ticket': ticket.value})
Valentin Samir's avatar
Valentin Samir committed
128 129
        return url

Valentin Samir's avatar
PEP8  
Valentin Samir committed
130

131 132
class ServicePatternException(Exception):
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
133 134


135
class BadUsername(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
136 137
    """Exception raised then an non allowed username
    try to get a ticket for a service"""
Valentin Samir's avatar
Valentin Samir committed
138
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
139 140


141
class BadFilter(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
142 143
    """"Exception raised then a user try
    to get a ticket for a service and do not reach a condition"""
Valentin Samir's avatar
Valentin Samir committed
144
    pass
Valentin Samir's avatar
Valentin Samir committed
145

Valentin Samir's avatar
PEP8  
Valentin Samir committed
146

147
class UserFieldNotDefined(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
148 149
    """Exception raised then a user try to get a ticket for a service
    using as username an attribut not present on this user"""
Valentin Samir's avatar
Valentin Samir committed
150
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
151 152


Valentin Samir's avatar
Valentin Samir committed
153
class ServicePattern(models.Model):
Valentin Samir's avatar
Valentin Samir committed
154
    """Allowed services pattern agains services are tested to"""
Valentin Samir's avatar
Valentin Samir committed
155 156 157
    class Meta:
        ordering = ("pos", )

158 159 160 161
    pos = models.IntegerField(
        default=100,
        verbose_name=_(u"position")
    )
Valentin Samir's avatar
Valentin Samir committed
162 163 164 165 166
    name = models.CharField(
        max_length=255,
        unique=True,
        blank=True,
        null=True,
167 168 169 170 171 172
        verbose_name=_(u"name"),
        help_text=_(u"A name for the service")
    )
    pattern = models.CharField(
        max_length=255,
        unique=True,
173 174 175 176 177 178
        verbose_name=_(u"pattern"),
        help_text=_(
            "A regular expression matching services. "
            "Will usually looks like '^https://some\\.server\\.com/path/.*$'."
            "As it is a regular expression, special character must be escaped with a '\\'."
        )
Valentin Samir's avatar
Valentin Samir committed
179 180 181 182 183
    )
    user_field = models.CharField(
        max_length=255,
        default="",
        blank=True,
184 185
        verbose_name=_(u"user field"),
        help_text=_("Name of the attribut to transmit as username, empty = login")
Valentin Samir's avatar
Valentin Samir committed
186 187 188
    )
    restrict_users = models.BooleanField(
        default=False,
189 190
        verbose_name=_(u"restrict username"),
        help_text=_("Limit username allowed to connect to the list provided bellow")
Valentin Samir's avatar
Valentin Samir committed
191 192 193
    )
    proxy = models.BooleanField(
        default=False,
194
        verbose_name=_(u"proxy"),
195 196 197 198 199 200
        help_text=_("Proxy tickets can be delivered to the service")
    )
    proxy_callback = models.BooleanField(
        default=False,
        verbose_name=_(u"proxy callback"),
        help_text=_("can be used as a proxy callback to deliver PGT")
Valentin Samir's avatar
Valentin Samir committed
201
    )
Valentin Samir's avatar
Valentin Samir committed
202
    single_log_out = models.BooleanField(
Valentin Samir's avatar
Valentin Samir committed
203
        default=False,
Valentin Samir's avatar
Valentin Samir committed
204 205
        verbose_name=_(u"single log out"),
        help_text=_("Enable SLO for the service")
Valentin Samir's avatar
Valentin Samir committed
206
    )
Valentin Samir's avatar
Valentin Samir committed
207

208 209 210 211 212
    single_log_out_callback = models.CharField(
        max_length=255,
        default="",
        blank=True,
        verbose_name=_(u"single log out callback"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
213
        help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
214 215 216
                    u"This is usefull for non HTTP proxied services.")
    )

Valentin Samir's avatar
Valentin Samir committed
217 218 219 220
    def __unicode__(self):
        return u"%s: %s" % (self.pos, self.pattern)

    def check_user(self, user):
Valentin Samir's avatar
Valentin Samir committed
221
        """Check if `user` if allowed to use theses services"""
Valentin Samir's avatar
Valentin Samir committed
222
        if self.restrict_users and not self.usernames.filter(value=user.username):
Valentin Samir's avatar
Valentin Samir committed
223
            raise BadUsername()
Valentin Samir's avatar
Valentin Samir committed
224
        for filtre in self.filters.all():
225 226
            if isinstance(user.attributs.get(filtre.attribut, []), list):
                attrs = user.attributs.get(filtre.attribut, [])
Valentin Samir's avatar
Valentin Samir committed
227
            else:
Valentin Samir's avatar
Valentin Samir committed
228 229 230
                attrs = [user.attributs[filtre.attribut]]
            for value in attrs:
                if re.match(filtre.pattern, str(value)):
Valentin Samir's avatar
Valentin Samir committed
231 232
                    break
            else:
Valentin Samir's avatar
Valentin Samir committed
233 234 235
                raise BadFilter('%s do not match %s %s' % (
                    filtre.pattern,
                    filtre.attribut,
Valentin Samir's avatar
Valentin Samir committed
236
                    user.attributs.get(filtre.attribut)
Valentin Samir's avatar
Valentin Samir committed
237
                ))
Valentin Samir's avatar
Valentin Samir committed
238 239 240 241 242 243
        if self.user_field and not user.attributs.get(self.user_field):
            raise UserFieldNotDefined()
        return True

    @classmethod
    def validate(cls, service):
Valentin Samir's avatar
Valentin Samir committed
244 245 246 247 248
        """Check if a Service Patern match `service` and
        return it, else raise `ServicePattern.DoesNotExist`"""
        for service_pattern in cls.objects.all().order_by('pos'):
            if re.match(service_pattern.pattern, service):
                return service_pattern
Valentin Samir's avatar
Valentin Samir committed
249 250
        raise cls.DoesNotExist()

Valentin Samir's avatar
PEP8  
Valentin Samir committed
251

Valentin Samir's avatar
Valentin Samir committed
252 253
class Username(models.Model):
    """A list of allowed usernames on a service pattern"""
254 255 256 257 258
    value = models.CharField(
        max_length=255,
        verbose_name=_(u"username"),
        help_text=_(u"username allowed to connect to the service")
    )
Valentin Samir's avatar
Valentin Samir committed
259
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
Valentin Samir's avatar
Valentin Samir committed
260

Valentin Samir's avatar
Valentin Samir committed
261 262 263
    def __unicode__(self):
        return self.value

Valentin Samir's avatar
PEP8  
Valentin Samir committed
264

Valentin Samir's avatar
Valentin Samir committed
265
class ReplaceAttributName(models.Model):
Valentin Samir's avatar
Valentin Samir committed
266
    """A list of replacement of attributs name for a service pattern"""
Valentin Samir's avatar
Valentin Samir committed
267
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
268
        unique_together = ('name', 'replace', 'service_pattern')
Valentin Samir's avatar
Valentin Samir committed
269 270
    name = models.CharField(
        max_length=255,
271
        verbose_name=_(u"name"),
Valentin Samir's avatar
Valentin Samir committed
272
        help_text=_(u"name of an attribut to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
273 274 275 276
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
277
        verbose_name=_(u"replace"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
278 279
        help_text=_(u"name under which the attribut will be show"
                    u"to the service. empty = default name of the attribut")
Valentin Samir's avatar
Valentin Samir committed
280
    )
Valentin Samir's avatar
Valentin Samir committed
281 282 283 284 285 286 287 288
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")

    def __unicode__(self):
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
289

Valentin Samir's avatar
Valentin Samir committed
290
class FilterAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
291 292 293
    """A list of filter on attributs for a service pattern"""
    attribut = models.CharField(
        max_length=255,
294 295
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
296 297 298
    )
    pattern = models.CharField(
        max_length=255,
299 300
        verbose_name=_(u"pattern"),
        help_text=_(u"a regular expression")
Valentin Samir's avatar
Valentin Samir committed
301
    )
Valentin Samir's avatar
Valentin Samir committed
302 303 304 305 306
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters")

    def __unicode__(self):
        return u"%s %s" % (self.attribut, self.pattern)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
307

Valentin Samir's avatar
Valentin Samir committed
308
class ReplaceAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
309 310 311
    """Replacement to apply on attributs values for a service pattern"""
    attribut = models.CharField(
        max_length=255,
312 313
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut for which the value must be replace")
Valentin Samir's avatar
Valentin Samir committed
314 315 316
    )
    pattern = models.CharField(
        max_length=255,
317 318
        verbose_name=_(u"pattern"),
        help_text=_(u"An regular expression maching whats need to be replaced")
Valentin Samir's avatar
Valentin Samir committed
319 320 321 322
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
323 324
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
325
    )
Valentin Samir's avatar
Valentin Samir committed
326 327 328 329
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")

    def __unicode__(self):
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
330 331


Valentin Samir's avatar
Valentin Samir committed
332
class Ticket(models.Model):
Valentin Samir's avatar
Valentin Samir committed
333
    """Generic class for a Ticket"""
Valentin Samir's avatar
Valentin Samir committed
334 335 336 337 338 339
    class Meta:
        abstract = True
    user = models.ForeignKey(User, related_name="%(class)s")
    attributs = PickledObjectField()
    validate = models.BooleanField(default=False)
    service = models.TextField()
Valentin Samir's avatar
Valentin Samir committed
340
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
Valentin Samir's avatar
Valentin Samir committed
341 342
    creation = models.DateTimeField(auto_now_add=True)
    renew = models.BooleanField(default=False)
343
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
344

Valentin Samir's avatar
Valentin Samir committed
345 346 347
    VALIDITY = settings.CAS_TICKET_VALIDITY
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

Valentin Samir's avatar
Valentin Samir committed
348
    def __unicode__(self):
349
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
350

351
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
352
    def clean_old_entries(cls):
353 354 355 356
        """Remove old ticket and send SLO to timed-out services"""
        # removing old validated ticket and non validated expired tickets
        cls.objects.filter(
            (
Valentin Samir's avatar
PEP8  
Valentin Samir committed
357 358 359 360
                Q(single_log_out=False) & Q(validate=True)
            ) | (
                Q(validate=False)
                & Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
361 362 363 364
            )
        ).delete()

        # sending SLO to timed-out validated tickets
Valentin Samir's avatar
Valentin Samir committed
365
        if cls.TIMEOUT and cls.TIMEOUT > 0:
366
            async_list = []
367 368 369
            session = FuturesSession(
                executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
            )
370
            queryset = cls.objects.filter(
Valentin Samir's avatar
Valentin Samir committed
371
                creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
372 373
            )
            for ticket in queryset:
374
                ticket.logout(None, session, async_list)
375 376 377 378 379 380 381 382
            queryset.delete()
            for future in async_list:
                if future:
                    try:
                        future.result()
                    except Exception as error:
                        sys.stderr.write("%r\n" % error)

383
    def logout(self, request, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
384
        """Send a SLO request to the ticket service"""
385 386 387
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
388
        if self.validate and self.single_log_out:
389 390
            try:
                xml = u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
Valentin Samir's avatar
Valentin Samir committed
391 392 393
     ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
    <saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
    <samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
Valentin Samir's avatar
Valentin Samir committed
394
  </samlp:LogoutRequest>""" % \
Valentin Samir's avatar
PEP8  
Valentin Samir committed
395 396 397 398 399
                    {
                        'id': os.urandom(20).encode("hex"),
                        'datetime': timezone.now().isoformat(),
                        'ticket':  self.value
                    }
400 401 402
                if self.service_pattern.single_log_out_callback:
                    url = self.service_pattern.single_log_out_callback
                else:
Valentin Samir's avatar
PEP8  
Valentin Samir committed
403
                    url = self.service
404 405 406 407
                async_list.append(
                    session.post(
                        url.encode('utf-8'),
                        data={'logoutRequest': xml.encode('utf-8')},
Valentin Samir's avatar
Valentin Samir committed
408
                        timeout=settings.CAS_SLO_TIMEOUT
409
                    )
Valentin Samir's avatar
Valentin Samir committed
410 411
                )
            except Exception as error:
412 413 414 415 416 417
                if request is not None:
                    error = utils.unpack_nested_exception(error)
                    messages.add_message(
                        request,
                        messages.WARNING,
                        _(u'Error during service logout %(service)s:\n%(error)s') %
Valentin Samir's avatar
PEP8  
Valentin Samir committed
418
                        {'service':  self.service, 'error': error}
419 420 421
                    )
                else:
                    sys.stderr.write("%r\n" % error)
Valentin Samir's avatar
Valentin Samir committed
422

Valentin Samir's avatar
PEP8  
Valentin Samir committed
423

Valentin Samir's avatar
Valentin Samir committed
424
class ServiceTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
425
    """A Service Ticket"""
426
    PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
427
    value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
428

Valentin Samir's avatar
Valentin Samir committed
429
    def __unicode__(self):
430
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
431 432


Valentin Samir's avatar
Valentin Samir committed
433
class ProxyTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
434
    """A Proxy Ticket"""
435
    PREFIX = settings.CAS_PROXY_TICKET_PREFIX
436
    value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
437

Valentin Samir's avatar
Valentin Samir committed
438
    def __unicode__(self):
439
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
440 441


Valentin Samir's avatar
Valentin Samir committed
442
class ProxyGrantingTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
443
    """A Proxy Granting Ticket"""
444
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
Valentin Samir's avatar
Valentin Samir committed
445
    VALIDITY = settings.CAS_PGT_VALIDITY
446
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
447

Valentin Samir's avatar
Valentin Samir committed
448
    def __unicode__(self):
449
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
450

Valentin Samir's avatar
PEP8  
Valentin Samir committed
451

Valentin Samir's avatar
Valentin Samir committed
452
class Proxy(models.Model):
Valentin Samir's avatar
Valentin Samir committed
453
    """A list of proxies on `ProxyTicket`"""
Valentin Samir's avatar
Valentin Samir committed
454 455 456 457 458
    class Meta:
        ordering = ("-pk", )
    url = models.CharField(max_length=255)
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")

Valentin Samir's avatar
Valentin Samir committed
459 460
    def __unicode__(self):
        return self.url