models.py revision 34dc5fa61c425389f9b931d3970c7556baf44dca
1import datetime 2from django.db import models as dbmodels, backend 3from django.utils import datastructures 4from frontend.afe import enum 5from frontend import settings 6 7 8class ValidationError(Exception): 9 """\ 10 Data validation error in adding or updating an object. The associated 11 value is a dictionary mapping field names to error strings. 12 """ 13 14 15class AclAccessViolation(Exception): 16 """\ 17 Raised when an operation is attempted with proper permissions as 18 dictated by ACLs. 19 """ 20 21 22class ExtendedManager(dbmodels.Manager): 23 """\ 24 Extended manager supporting subquery filtering. 25 """ 26 27 class _RawSqlQ(dbmodels.Q): 28 """\ 29 A Django "Q" object constructed with a raw SQL query. 30 """ 31 def __init__(self, sql, params=[]): 32 self._sql = sql 33 self._params = params[:] 34 35 36 def get_sql(self, opts): 37 return (datastructures.SortedDict(), 38 [self._sql], 39 self._params) 40 41 42 @staticmethod 43 def _get_quoted_field(table, field): 44 return (backend.quote_name(table) + '.' + 45 backend.quote_name(field)) 46 47 48 def _do_subquery_filter(self, key_field, subquery, not_in=False): 49 select_fields, where, params = subquery._get_sql_clause() 50 subquery_table = subquery.model._meta.db_table 51 subselect = '(SELECT ' + self._get_quoted_field(subquery_table, 52 key_field) 53 subselect += where + ')' 54 55 in_str = not_in and 'NOT IN' or 'IN' 56 pk_field = self._get_quoted_field(self.model._meta.db_table, 57 self.model._meta.pk.column) 58 where_sql = ' '.join((pk_field, in_str, subselect)) 59 filter_obj = self._RawSqlQ(where_sql, params) 60 return self.complex_filter(filter_obj) 61 62 63 def filter_in_subquery(self, key_field, subquery): 64 """\ 65 Construct a filter to perform a subquery match, i.e. 66 WHERE id IN (SELECT host_id FROM ... WHERE ...) 67 -key_field - the field to select in the subquery (host_id above) 68 -subquery - a query object for the subquery 69 """ 70 return self._do_subquery_filter(key_field, subquery) 71 72 73 def filter_not_in_subquery(self, key_field, subquery): 74 'Like filter_in_subquery, but use NOT IN rather than IN.' 75 return self._do_subquery_filter(key_field, subquery, 76 not_in=True) 77 78 79class ValidObjectsManager(ExtendedManager): 80 """ 81 Manager returning only objects with invalid=False. 82 """ 83 def get_query_set(self): 84 queryset = super(ValidObjectsManager, self).get_query_set() 85 return queryset.filter(invalid=False) 86 87 88class ModelExtensions(object): 89 """\ 90 Mixin with convenience functions for models, built on top of the 91 default Django model functions. 92 """ 93 # TODO: at least some of these functions really belong in a custom 94 # Manager class 95 96 field_dict = None 97 # subclasses should override if they want to support smart_get() by name 98 name_field = None 99 100 101 @classmethod 102 def get_field_dict(cls): 103 if cls.field_dict is None: 104 cls.field_dict = {} 105 for field in cls._meta.fields: 106 cls.field_dict[field.name] = field 107 return cls.field_dict 108 109 110 @classmethod 111 def clean_foreign_keys(cls, data): 112 """\ 113 Convert foreign key fields in data from <field>_id to just 114 <field> 115 """ 116 for field in cls._meta.fields: 117 if field.rel and field.attname != field.name: 118 data[field.name] = data[field.attname] 119 del data[field.attname] 120 121 122 # TODO(showard) - is there a way to not have to do this? 123 @classmethod 124 def provide_default_values(cls, data): 125 """\ 126 Provide default values for fields with default values which have 127 nothing passed in. 128 129 For CharField and TextField fields with "blank=True", if nothing 130 is passed, we fill in an empty string value, even if there's no 131 default set. 132 """ 133 new_data = dict(data) 134 field_dict = cls.get_field_dict() 135 for name, obj in field_dict.iteritems(): 136 if data.get(name) is not None: 137 continue 138 if obj.default is not dbmodels.fields.NOT_PROVIDED: 139 new_data[name] = obj.default 140 elif (isinstance(obj, dbmodels.CharField) or 141 isinstance(obj, dbmodels.TextField)): 142 new_data[name] = '' 143 return new_data 144 145 146 @classmethod 147 def convert_human_readable_values(cls, data, to_human_readable=False): 148 """\ 149 Performs conversions on user-supplied field data, to make it 150 easier for users to pass human-readable data. 151 152 For all fields that have choice sets, convert their values 153 from human-readable strings to enum values, if necessary. This 154 allows users to pass strings instead of the corresponding 155 integer values. 156 157 For all foreign key fields, call smart_get with the supplied 158 data. This allows the user to pass either an ID value or 159 the name of the object as a string. 160 161 If to_human_readable=True, perform the inverse - i.e. convert 162 numeric values to human readable values. 163 """ 164 new_data = dict(data) 165 field_dict = cls.get_field_dict() 166 for field_name in data: 167 if data[field_name] is None: 168 continue 169 field_obj = field_dict[field_name] 170 # convert enum values 171 if field_obj.choices: 172 for choice_data in field_obj.choices: 173 # choice_data is (value, name) 174 if to_human_readable: 175 from_val, to_val = choice_data 176 else: 177 to_val, from_val = choice_data 178 if from_val == data[field_name]: 179 new_data[field_name] = to_val 180 break 181 # convert foreign key values 182 elif field_obj.rel: 183 dest_obj = field_obj.rel.to.smart_get( 184 data[field_name]) 185 if (to_human_readable and 186 dest_obj.name_field is not None): 187 new_data[field_name] = ( 188 getattr(dest_obj, 189 dest_obj.name_field)) 190 else: 191 new_data[field_name] = dest_obj.id 192 return new_data 193 194 195 @classmethod 196 def validate_field_names(cls, data): 197 'Checks for extraneous fields in data.' 198 errors = {} 199 field_dict = cls.get_field_dict() 200 for field_name in data: 201 if field_name not in field_dict: 202 errors[field_name] = 'No field of this name' 203 return errors 204 205 206 @classmethod 207 def prepare_data_args(cls, data, kwargs): 208 'Common preparation for add_object and update_object' 209 data = dict(data) # don't modify the default keyword arg 210 data.update(kwargs) 211 # must check for extraneous field names here, while we have the 212 # data in a dict 213 errors = cls.validate_field_names(data) 214 if errors: 215 raise ValidationError(errors) 216 data = cls.convert_human_readable_values(data) 217 return data 218 219 220 def validate_unique(self): 221 """\ 222 Validate that unique fields are unique. Django manipulators do 223 this too, but they're a huge pain to use manually. Trust me. 224 """ 225 errors = {} 226 cls = type(self) 227 field_dict = self.get_field_dict() 228 manager = cls.get_valid_manager() 229 for field_name, field_obj in field_dict.iteritems(): 230 if not field_obj.unique: 231 continue 232 233 value = getattr(self, field_name) 234 existing_objs = manager.filter(**{field_name : value}) 235 num_existing = existing_objs.count() 236 237 if num_existing == 0: 238 continue 239 if num_existing == 1 and existing_objs[0].id == self.id: 240 continue 241 errors[field_name] = ( 242 'This value must be unique (%s)' % (value)) 243 return errors 244 245 246 def do_validate(self): 247 errors = self.validate() 248 unique_errors = self.validate_unique() 249 for field_name, error in unique_errors.iteritems(): 250 errors.setdefault(field_name, error) 251 if errors: 252 raise ValidationError(errors) 253 254 255 # actually (externally) useful methods follow 256 257 @classmethod 258 def add_object(cls, data={}, **kwargs): 259 """\ 260 Returns a new object created with the given data (a dictionary 261 mapping field names to values). Merges any extra keyword args 262 into data. 263 """ 264 data = cls.prepare_data_args(data, kwargs) 265 data = cls.provide_default_values(data) 266 obj = cls(**data) 267 obj.do_validate() 268 obj.save() 269 return obj 270 271 272 def update_object(self, data={}, **kwargs): 273 """\ 274 Updates the object with the given data (a dictionary mapping 275 field names to values). Merges any extra keyword args into 276 data. 277 """ 278 data = self.prepare_data_args(data, kwargs) 279 for field_name, value in data.iteritems(): 280 if value is not None: 281 setattr(self, field_name, value) 282 self.do_validate() 283 self.save() 284 285 286 @classmethod 287 def query_objects(cls, filter_data, valid_only=True): 288 """\ 289 Returns a QuerySet object for querying the given model_class 290 with the given filter_data. Optional special arguments in 291 filter_data include: 292 -query_start: index of first return to return 293 -query_limit: maximum number of results to return 294 -sort_by: list of fields to sort on. prefixing a '-' onto a 295 field name changes the sort to descending order. 296 -extra_args: keyword args to pass to query.extra() (see Django 297 DB layer documentation) 298 """ 299 query_start = filter_data.pop('query_start', None) 300 query_limit = filter_data.pop('query_limit', None) 301 if query_start and not query_limit: 302 raise ValueError('Cannot pass query_start without ' 303 'query_limit') 304 sort_by = filter_data.pop('sort_by', []) 305 extra_args = filter_data.pop('extra_args', None) 306 query_dict = {} 307 for field, value in filter_data.iteritems(): 308 query_dict[field] = value 309 if valid_only: 310 manager = cls.get_valid_manager() 311 else: 312 manager = cls.objects 313 query = manager.filter(**query_dict).distinct() 314 if extra_args: 315 query = query.extra(**extra_args) 316 assert isinstance(sort_by, list) or isinstance(sort_by, tuple) 317 query = query.order_by(*sort_by) 318 if query_start is not None and query_limit is not None: 319 query_limit += query_start 320 return query[query_start:query_limit] 321 322 323 @classmethod 324 def query_count(cls, filter_data): 325 """\ 326 Like query_objects, but retreive only the count of results. 327 """ 328 filter_data.pop('query_start', None) 329 filter_data.pop('query_limit', None) 330 return cls.query_objects(filter_data).count() 331 332 333 @classmethod 334 def clean_object_dicts(cls, field_dicts): 335 """\ 336 Take a list of dicts corresponding to object (as returned by 337 query.values()) and clean the data to be more suitable for 338 returning to the user. 339 """ 340 for i in range(len(field_dicts)): 341 cls.clean_foreign_keys(field_dicts[i]) 342 field_dicts[i] = cls.convert_human_readable_values( 343 field_dicts[i], to_human_readable=True) 344 345 346 @classmethod 347 def list_objects(cls, filter_data): 348 """\ 349 Like query_objects, but return a list of dictionaries. 350 """ 351 query = cls.query_objects(filter_data) 352 field_dicts = list(query.values()) 353 cls.clean_object_dicts(field_dicts) 354 return field_dicts 355 356 357 @classmethod 358 def smart_get(cls, *args, **kwargs): 359 """\ 360 smart_get(integer) -> get object by ID 361 smart_get(string) -> get object by name_field 362 smart_get(keyword args) -> normal ModelClass.objects.get() 363 """ 364 assert bool(args) ^ bool(kwargs) 365 if args: 366 assert len(args) == 1 367 arg = args[0] 368 if isinstance(arg, int) or isinstance(arg, long): 369 return cls.objects.get(id=arg) 370 if isinstance(arg, str) or isinstance(arg, unicode): 371 return cls.objects.get( 372 **{cls.name_field : arg}) 373 raise ValueError( 374 'Invalid positional argument: %s (%s)' % ( 375 str(arg), type(arg))) 376 return cls.objects.get(**kwargs) 377 378 379 def get_object_dict(self): 380 """\ 381 Return a dictionary mapping fields to this object's values. 382 """ 383 return dict((field_name, getattr(self, field_name)) 384 for field_name in self.get_field_dict().iterkeys()) 385 386 387 @classmethod 388 def get_valid_manager(cls): 389 return cls.objects 390 391 392class ModelWithInvalid(ModelExtensions): 393 """ 394 Overrides model methods save() and delete() to support invalidation in 395 place of actual deletion. Subclasses must have a boolean "invalid" 396 field. 397 """ 398 399 def save(self): 400 # see if this object was previously added and invalidated 401 my_name = getattr(self, self.name_field) 402 filters = {self.name_field : my_name, 'invalid' : True} 403 try: 404 old_object = self.__class__.objects.get(**filters) 405 except self.DoesNotExist: 406 # no existing object 407 super(ModelWithInvalid, self).save() 408 return 409 410 self.id = old_object.id 411 super(ModelWithInvalid, self).save() 412 413 414 def clean_object(self): 415 """ 416 This method is called when an object is marked invalid. 417 Subclasses should override this to clean up relationships that 418 should no longer exist if the object were deleted.""" 419 pass 420 421 422 def delete(self): 423 assert not self.invalid 424 self.invalid = True 425 self.save() 426 self.clean_object() 427 428 429 @classmethod 430 def get_valid_manager(cls): 431 return cls.valid_objects 432 433 434 class Manipulator(object): 435 """ 436 Force default manipulators to look only at valid objects - 437 otherwise they will match against invalid objects when checking 438 uniqueness. 439 """ 440 @classmethod 441 def _prepare(cls, model): 442 super(ModelWithInvalid.Manipulator, cls)._prepare(model) 443 cls.manager = model.valid_objects 444 445 446class Label(ModelWithInvalid, dbmodels.Model): 447 """\ 448 Required: 449 name: label name 450 451 Optional: 452 kernel_config: url/path to kernel config to use for jobs run on this 453 label 454 platform: if True, this is a platform label (defaults to False) 455 """ 456 name = dbmodels.CharField(maxlength=255, unique=True) 457 kernel_config = dbmodels.CharField(maxlength=255, blank=True) 458 platform = dbmodels.BooleanField(default=False) 459 invalid = dbmodels.BooleanField(default=False, 460 editable=settings.FULL_ADMIN) 461 462 name_field = 'name' 463 objects = ExtendedManager() 464 valid_objects = ValidObjectsManager() 465 466 def clean_object(self): 467 self.host_set.clear() 468 469 470 def enqueue_job(self, job): 471 'Enqueue a job on any host of this label.' 472 queue_entry = HostQueueEntry(meta_host=self, job=job, 473 status=Job.Status.QUEUED, 474 priority=job.priority) 475 queue_entry.save() 476 477 478 class Meta: 479 db_table = 'labels' 480 481 class Admin: 482 list_display = ('name', 'kernel_config') 483 # see Host.Admin 484 manager = ValidObjectsManager() 485 486 def __str__(self): 487 return self.name 488 489 490class Host(ModelWithInvalid, dbmodels.Model): 491 """\ 492 Required: 493 hostname 494 495 optional: 496 locked: host is locked and will not be queued 497 498 Internal: 499 synch_id: currently unused 500 status: string describing status of host 501 """ 502 Status = enum.Enum('Verifying', 'Running', 'Ready', 'Repairing', 503 'Repair Failed', 'Dead', string_values=True) 504 505 hostname = dbmodels.CharField(maxlength=255, unique=True) 506 labels = dbmodels.ManyToManyField(Label, blank=True, 507 filter_interface=dbmodels.HORIZONTAL) 508 locked = dbmodels.BooleanField(default=False) 509 synch_id = dbmodels.IntegerField(blank=True, null=True, 510 editable=settings.FULL_ADMIN) 511 status = dbmodels.CharField(maxlength=255, default=Status.READY, 512 choices=Status.choices(), 513 editable=settings.FULL_ADMIN) 514 invalid = dbmodels.BooleanField(default=False, 515 editable=settings.FULL_ADMIN) 516 517 name_field = 'hostname' 518 objects = ExtendedManager() 519 valid_objects = ValidObjectsManager() 520 521 522 def clean_object(self): 523 self.aclgroup_set.clear() 524 self.labels.clear() 525 526 527 def save(self): 528 # extra spaces in the hostname can be a sneaky source of errors 529 self.hostname = self.hostname.strip() 530 # is this a new object being saved for the first time? 531 first_time = (self.id is None) 532 super(Host, self).save() 533 if first_time: 534 everyone = AclGroup.objects.get(name='Everyone') 535 everyone.hosts.add(self) 536 537 538 def enqueue_job(self, job): 539 ' Enqueue a job on this host.' 540 queue_entry = HostQueueEntry(host=self, job=job, 541 status=Job.Status.QUEUED, 542 priority=job.priority) 543 # allow recovery of dead hosts from the frontend 544 if not self.active_queue_entry() and self.is_dead(): 545 self.status = Host.Status.READY 546 self.save() 547 queue_entry.save() 548 549 550 def platform(self): 551 # TODO(showard): slighly hacky? 552 platforms = self.labels.filter(platform=True) 553 if len(platforms) == 0: 554 return None 555 return platforms[0] 556 platform.short_description = 'Platform' 557 558 559 def is_dead(self): 560 return self.status == Host.Status.REPAIR_FAILED 561 562 563 def active_queue_entry(self): 564 active = list(self.hostqueueentry_set.filter(active=True)) 565 if not active: 566 return None 567 assert len(active) == 1, ('More than one active entry for ' 568 'host ' + self.hostname) 569 return active[0] 570 571 572 class Meta: 573 db_table = 'hosts' 574 575 class Admin: 576 # TODO(showard) - showing platform requires a SQL query for 577 # each row (since labels are many-to-many) - should we remove 578 # it? 579 list_display = ('hostname', 'platform', 'locked', 'status') 580 list_filter = ('labels', 'locked') 581 search_fields = ('hostname', 'status') 582 # undocumented Django feature - if you set manager here, the 583 # admin code will use it, otherwise it'll use a default Manager 584 manager = ValidObjectsManager() 585 586 def __str__(self): 587 return self.hostname 588 589 590class Test(dbmodels.Model, ModelExtensions): 591 """\ 592 Required: 593 name: test name 594 test_type: Client or Server 595 path: path to pass to run_test() 596 synch_type: whether the test should run synchronously or asynchronously 597 598 Optional: 599 test_class: used for categorization of tests 600 description: arbirary text description 601 """ 602 Classes = enum.Enum('Kernel', 'Hardware', 'Canned Test Sets', 603 string_values=True) 604 SynchType = enum.Enum('Asynchronous', 'Synchronous', start_value=1) 605 # TODO(showard) - this should be merged with Job.ControlType (but right 606 # now they use opposite values) 607 Types = enum.Enum('Client', 'Server', start_value=1) 608 609 name = dbmodels.CharField(maxlength=255, unique=True) 610 test_class = dbmodels.CharField(maxlength=255, 611 choices=Classes.choices()) 612 description = dbmodels.TextField(blank=True) 613 test_type = dbmodels.SmallIntegerField(choices=Types.choices()) 614 synch_type = dbmodels.SmallIntegerField(choices=SynchType.choices(), 615 default=SynchType.ASYNCHRONOUS) 616 path = dbmodels.CharField(maxlength=255) 617 618 name_field = 'name' 619 objects = ExtendedManager() 620 621 622 class Meta: 623 db_table = 'autotests' 624 625 class Admin: 626 fields = ( 627 (None, {'fields' : 628 ('name', 'test_class', 'test_type', 'synch_type', 629 'path', 'description')}), 630 ) 631 list_display = ('name', 'test_type', 'synch_type', 632 'description') 633 search_fields = ('name',) 634 635 def __str__(self): 636 return self.name 637 638 639class User(dbmodels.Model, ModelExtensions): 640 """\ 641 Required: 642 login :user login name 643 644 Optional: 645 access_level: 0=User (default), 1=Admin, 100=Root 646 """ 647 ACCESS_ROOT = 100 648 ACCESS_ADMIN = 1 649 ACCESS_USER = 0 650 651 login = dbmodels.CharField(maxlength=255, unique=True) 652 access_level = dbmodels.IntegerField(default=ACCESS_USER, blank=True) 653 654 name_field = 'login' 655 objects = ExtendedManager() 656 657 658 def save(self): 659 # is this a new object being saved for the first time? 660 first_time = (self.id is None) 661 super(User, self).save() 662 if first_time: 663 everyone = AclGroup.objects.get(name='Everyone') 664 everyone.users.add(self) 665 666 667 def has_access(self, target): 668 if self.access_level >= self.ACCESS_ROOT: 669 return True 670 671 if isinstance(target, int): 672 return self.access_level >= target 673 if isinstance(target, Job): 674 return (target.owner == self.login or 675 self.access_level >= self.ACCESS_ADMIN) 676 if isinstance(target, Host): 677 acl_intersect = [group 678 for group in self.aclgroup_set.all() 679 if group in target.aclgroup_set.all()] 680 return bool(acl_intersect) 681 if isinstance(target, User): 682 return self.access_level >= target.access_level 683 raise ValueError('Invalid target type') 684 685 686 class Meta: 687 db_table = 'users' 688 689 class Admin: 690 list_display = ('login', 'access_level') 691 search_fields = ('login',) 692 693 def __str__(self): 694 return self.login 695 696 697class AclGroup(dbmodels.Model, ModelExtensions): 698 """\ 699 Required: 700 name: name of ACL group 701 702 Optional: 703 description: arbitrary description of group 704 """ 705 # REMEMBER: whenever ACL membership changes, something MUST call 706 # Job.recompute_all_blocks(). 707 name = dbmodels.CharField(maxlength=255, unique=True) 708 description = dbmodels.CharField(maxlength=255, blank=True) 709 users = dbmodels.ManyToManyField(User, 710 filter_interface=dbmodels.HORIZONTAL) 711 hosts = dbmodels.ManyToManyField(Host, 712 filter_interface=dbmodels.HORIZONTAL) 713 714 name_field = 'name' 715 objects = ExtendedManager() 716 717 718 # need to recompute blocks on group deletion 719 def delete(self): 720 super(AclGroup, self).delete() 721 Job.recompute_all_blocks() 722 723 724 # if you have a model attribute called "Manipulator", Django will 725 # automatically insert it into the beginning of the superclass list 726 # for the model's manipulators 727 class Manipulator(object): 728 """ 729 Custom manipulator to recompute job blocks whenever ACLs are 730 added or membership is changed through manipulators. 731 """ 732 def save(self, new_data): 733 obj = super(AclGroup.Manipulator, self).save(new_data) 734 Job.recompute_all_blocks() 735 return obj 736 737 738 class Meta: 739 db_table = 'acl_groups' 740 741 class Admin: 742 list_display = ('name', 'description') 743 search_fields = ('name',) 744 745 def __str__(self): 746 return self.name 747 748# hack to make the column name in the many-to-many DB tables match the one 749# generated by ruby 750AclGroup._meta.object_name = 'acl_group' 751 752 753class JobManager(ExtendedManager): 754 'Custom manager to provide efficient status counts querying.' 755 def get_status_counts(self, job_ids): 756 """\ 757 Returns a dictionary mapping the given job IDs to their status 758 count dictionaries. 759 """ 760 if not job_ids: 761 return {} 762 id_list = '(%s)' % ','.join(str(job_id) for job_id in job_ids) 763 from django.db import connection 764 cursor = connection.cursor() 765 cursor.execute(""" 766 SELECT job_id, status, COUNT(*) 767 FROM host_queue_entries 768 WHERE job_id IN %s 769 GROUP BY job_id, status 770 """ % id_list) 771 all_job_counts = {} 772 for job_id in job_ids: 773 all_job_counts[job_id] = {} 774 for job_id, status, count in cursor.fetchall(): 775 all_job_counts[job_id][status] = count 776 return all_job_counts 777 778 779class Job(dbmodels.Model, ModelExtensions): 780 """\ 781 owner: username of job owner 782 name: job name (does not have to be unique) 783 priority: Low, Medium, High, Urgent (or 0-3) 784 control_file: contents of control file 785 control_type: Client or Server 786 created_on: date of job creation 787 submitted_on: date of job submission 788 synch_type: Asynchronous or Synchronous (i.e. job must run on all hosts 789 simultaneously; used for server-side control files) 790 synch_count: ??? 791 synchronizing: for scheduler use 792 """ 793 Priority = enum.Enum('Low', 'Medium', 'High', 'Urgent') 794 ControlType = enum.Enum('Server', 'Client', start_value=1) 795 Status = enum.Enum('Created', 'Queued', 'Pending', 'Running', 796 'Completed', 'Abort', 'Aborting', 'Aborted', 797 'Failed', string_values=True) 798 799 owner = dbmodels.CharField(maxlength=255) 800 name = dbmodels.CharField(maxlength=255) 801 priority = dbmodels.SmallIntegerField(choices=Priority.choices(), 802 blank=True, # to allow 0 803 default=Priority.MEDIUM) 804 control_file = dbmodels.TextField() 805 control_type = dbmodels.SmallIntegerField(choices=ControlType.choices(), 806 blank=True) # to allow 0 807 created_on = dbmodels.DateTimeField(auto_now_add=True) 808 synch_type = dbmodels.SmallIntegerField( 809 blank=True, null=True, choices=Test.SynchType.choices()) 810 synch_count = dbmodels.IntegerField(blank=True, null=True) 811 synchronizing = dbmodels.BooleanField(default=False) 812 813 814 # custom manager 815 objects = JobManager() 816 817 818 def is_server_job(self): 819 return self.control_type == self.ControlType.SERVER 820 821 822 @classmethod 823 def create(cls, owner, name, priority, control_file, control_type, 824 hosts, synch_type): 825 """\ 826 Creates a job by taking some information (the listed args) 827 and filling in the rest of the necessary information. 828 """ 829 job = cls.add_object( 830 owner=owner, name=name, priority=priority, 831 control_file=control_file, control_type=control_type, 832 synch_type=synch_type) 833 834 if job.synch_type == Test.SynchType.SYNCHRONOUS: 835 job.synch_count = len(hosts) 836 else: 837 if len(hosts) == 0: 838 errors = {'hosts': 839 'asynchronous jobs require at least' 840 + ' one host to run on'} 841 raise ValidationError(errors) 842 job.save() 843 return job 844 845 846 def queue(self, hosts): 847 'Enqueue a job on the given hosts.' 848 for host in hosts: 849 host.enqueue_job(self) 850 self.recompute_blocks() 851 852 853 def recompute_blocks(self): 854 """\ 855 Clear out the blocks (ineligible_host_queues) for this job and 856 recompute the set. The set of blocks is the union of: 857 -all hosts already assigned to this job 858 -all hosts not ACL accessible to this job's owner 859 """ 860 job_entries = self.hostqueueentry_set.all() 861 accessible_hosts = Host.objects.filter( 862 acl_group__users__login=self.owner) 863 query = Host.objects.filter_in_subquery('host_id', job_entries) 864 query |= Host.objects.filter_not_in_subquery('id', 865 accessible_hosts) 866 867 old_ids = [block.id for block in 868 self.ineligiblehostqueue_set.all()] 869 for host in query: 870 block = IneligibleHostQueue(job=self, host=host) 871 block.save() 872 IneligibleHostQueue.objects.filter(id__in=old_ids).delete() 873 874 875 @classmethod 876 def recompute_all_blocks(cls): 877 'Recompute blocks for all queued and active jobs.' 878 for job in cls.objects.filter(hostqueueentry__complete=False): 879 job.recompute_blocks() 880 881 882 def requeue(self, new_owner): 883 'Creates a new job identical to this one' 884 hosts = [queue_entry.meta_host or queue_entry.host 885 for queue_entry in self.hostqueueentry_set.all()] 886 new_job = Job.create( 887 owner=new_owner, name=self.name, priority=self.priority, 888 control_file=self.control_file, 889 control_type=self.control_type, hosts=hosts, 890 synch_type=self.synch_type) 891 new_job.queue(hosts) 892 return new_job 893 894 895 def abort(self): 896 for queue_entry in self.hostqueueentry_set.all(): 897 if queue_entry.active: 898 queue_entry.status = Job.Status.ABORT 899 elif not queue_entry.complete: 900 queue_entry.status = Job.Status.ABORTED 901 queue_entry.active = False 902 queue_entry.complete = True 903 queue_entry.save() 904 905 906 def user(self): 907 try: 908 return User.objects.get(login=self.owner) 909 except self.DoesNotExist: 910 return None 911 912 913 class Meta: 914 db_table = 'jobs' 915 916 if settings.FULL_ADMIN: 917 class Admin: 918 list_display = ('id', 'owner', 'name', 'control_type') 919 920 def __str__(self): 921 return '%s (%s-%s)' % (self.name, self.id, self.owner) 922 923 924class IneligibleHostQueue(dbmodels.Model): 925 job = dbmodels.ForeignKey(Job) 926 host = dbmodels.ForeignKey(Host) 927 928 objects = ExtendedManager() 929 930 class Meta: 931 db_table = 'ineligible_host_queues' 932 933 if settings.FULL_ADMIN: 934 class Admin: 935 list_display = ('id', 'job', 'host') 936 937 938class HostQueueEntry(dbmodels.Model, ModelExtensions): 939 job = dbmodels.ForeignKey(Job) 940 host = dbmodels.ForeignKey(Host, blank=True, null=True) 941 priority = dbmodels.SmallIntegerField() 942 status = dbmodels.CharField(maxlength=255) 943 meta_host = dbmodels.ForeignKey(Label, blank=True, null=True, 944 db_column='meta_host') 945 active = dbmodels.BooleanField(default=False) 946 complete = dbmodels.BooleanField(default=False) 947 948 objects = ExtendedManager() 949 950 951 def is_meta_host_entry(self): 952 'True if this is a entry has a meta_host instead of a host.' 953 return self.host is None and self.meta_host is not None 954 955 956 class Meta: 957 db_table = 'host_queue_entries' 958 959 if settings.FULL_ADMIN: 960 class Admin: 961 list_display = ('id', 'job', 'host', 'status', 962 'meta_host') 963