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