This commit is contained in:
ross
2020-04-11 18:33:17 +01:00
parent 93aa4e6e6a
commit 1b4cca40f8
3 changed files with 672 additions and 805 deletions
+117 -766
View File
File diff suppressed because one or more lines are too long
+525 -16
View File
@@ -2,6 +2,11 @@ import datetime
import itertools
from typing import List, Tuple
from workers import Worker
from pyomo.environ import *
from pyomo.opt import SolverFactory
ShiftName = str
DayStr = str
WeekInt = int
@@ -48,7 +53,8 @@ class SingleShift(object):
self.total_shifts = length * len(days) * len(shift_days)
class ShiftCollection(object):
class RotaBuilder(object):
"""Class to hold and manipulate shifts"""
def __init__(self, weeks_to_rota=26):
@@ -64,6 +70,507 @@ class ShiftCollection(object):
self.unavailable_to_work = set()
self.workers = []
self.night_blocks = ["weekday", "weekend", "none"]
def build_model(self):
# Initialize model
self.model = ConcreteModel()
# binary variables representing if a worker is scheduled somewhere
# NOTE: this will assign to all possible shift combinations (which we probably don't want)
self.model.works = Var(
(
(worker.id, week, day, shift)
for worker in self.workers
for week, day in self.get_week_day_combinations()
for shift in self.get_shift_names()
),
within=Binary,
initialize=0,
)
# The nights self.model is used to ensure nights are assigned as a block (and limit the number of workers required)
self.model.nights = Var(
(
(worker.id, week, block)
for worker in self.workers
for week in self.weeks
for block in self.night_blocks
),
within=Binary,
initialize=0,
)
self.model.shift_count = Var(
(
(worker.id, shift)
for worker in self.workers
for shift in self.get_shift_names()
),
domain=NonNegativeReals,
initialize=0,
)
# self.model.shift_count_t1 = Var(((worker.id, shift) for worker in self.workers for shift in self.get_shift_names()), domain=NonNegativeReals, initialize=0)
# self.model.shift_count_t2 = Var(((worker.id, shift) for worker in self.workers for shift in self.get_shift_names()), domain=NonNegativeReals, initialize=0)
self.model.shift_count_t1 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
)
self.model.shift_count_t2 = Var(
((worker.id) for worker in self.workers),
domain=NonNegativeReals,
initialize=0,
)
self.model.shift_count_t3 = Var(
(worker.id for worker in self.workers), initialize=0
)
# self.model.unavailable = Var(((worker, week, day) for worker in self.workers for week in weeks for day in days),
# within=Binary, initialize=0)
def availability_init(model, wid, week, day):
if (wid, week, day) in self.unavailable_to_work:
# print((wid, week, day))
return 0
return 1
self.model.available = Param(
(
(worker.id, week, day)
for worker in self.workers
for week, day in self.get_week_day_combinations()
),
initialize=availability_init,
)
# binary variables representing if a worker is necessary
# self.model.needed = Var([worker.id for worker in self.workers], within=Binary, initialize=0)
# binary variables representing if a worker worked on sunday but not on saturday (avoid if possible)
# self.model.no_pref = Var([worker.id for worker in self.workers], within=Binary, initialize=0)
self.build_model_constraints()
def build_model_constraints(self):
self.model.constraints = ConstraintList() # Create a set of constraints
for (
week,
day,
shift,
self.workers_required,
site_required,
) in self.get_required_workers_and_site_combinations():
# print(week, day, shift, self.workers_required, site_required)
self.model.constraints.add(
self.workers_required
== sum(
self.model.works[worker.id, week, day, shift]
for worker in self.workers
if worker.site in site_required
)
)
for week, day, shift in self.get_not_required_shifts():
self.model.constraints.add(
0
== sum(
self.model.works[worker.id, week, day, shift.name]
for worker in self.workers
)
)
# Constraint: total hours worked hours worked
# for worker in self.workers:
# self.model.constraints.add(
# 1200 >= sum(shift_lengths[shift] * self.model.works[worker, week, day, shift] for week in weeks for day in days for shift in days_shifts[day])
# )
# for week in weeks:
# for worker in self.workers:
# self.model.constraints.add(
# 48 >= sum(shift_lengths[shift] * self.model.works[worker, week, day, shift] for day in days for shift in days_shifts[day])
# )
def maxHoursPerWeekRule(model, wid, week):
return 72 >= sum(
shift.length * model.works[wid, week, day, shift.name]
for day, shift in self.get_day_shiftclass_products()
)
self.model.max_hours_per_week_constraint = Constraint(
[worker.id for worker in self.workers], self.weeks, rule=maxHoursPerWeekRule
)
def maxHoursRule(model, wid):
return 8000 >= sum(
shift.length * model.works[wid, week, day, shift.name]
for week, day, shift in self.get_all_shiftclass_combinations()
)
self.model.max_hours_constraint = Constraint(
[worker.id for worker in self.workers], rule=maxHoursRule
)
# self should not be allocated on nwds (excepting night shifts)
def nwdRule(model, worker):
print(worker, worker.id, worker.nwd)
wid = worker.id
nwd = worker.nwd
return 0 == sum(
model.works[wid, week, day, shift.name]
for week, day, shift in self.get_all_shiftclass_combinations()
if (not shift.rota_on_nwds and day in nwd)
)
# self.model.nwd_constraint = Constraint([(worker) for worker in self.workers if worker.nwd], rule=nwdRule)
# Set a rule to even twilight shifts:
# total_twilight_shifts = len(weeks) * 5 * 1 # One person per site
# def trurotwilightShiftNumberRule(self.model, wid, fte_adj, site, shift):
# #site, shift = site_and_shift
# max_shifts = total_twilight_shifts / self.full_time_equivalent_sites[site] * fte_adj + twilight_balance_offset
# min_shifts = total_twilight_shifts / self.full_time_equivalent_sites[site] * fte_adj - twilight_balance_offset
# return inequality(min_shifts, sum(self.model.works[wid, week, day, shift.GetShift()] for week in weeks for day in days), max_shifts)
# self.model.twilight_shifts_truro_constraint = Constraint([(worker.id, worker.fte_adj, ss[0], ss[1]) for ss in site_and_shifts for worker in self.workers if worker.site == ss[0]], rule=trurotwilightShiftNumberRule)
# def plymouthtwilightShiftNumberRule(self.model, wid, fte_adj):
# max_shifts = total_twilight_shifts / self.full_time_equivalent_sites["plymouth"] * fte_adj + twilight_balance_offset
# min_shifts = total_twilight_shifts / self.full_time_equivalent_sites["plymouth"] * fte_adj - twilight_balance_offset
#
# return inequality(min_shifts, sum(self.model.works[wid, week, day, "plymouth_twilight"] for week in weeks for day in days), max_shifts)
# self.model.twilight_shifts_plymouth_constraint = Constraint([(worker.id, worker.fte_adj) for worker in self.workers if worker.site == "plymouth"], #rule=plymouthtwilightShiftNumberRule)
# Limit to 1 ST2 (or below) on a night shift
def nightShiftMaxSTRule(model, week, block):
"""Limits to 1 ST2 (or below) per night"""
single_workers = [w for w in self.workers if w.grade < 3]
if not single_workers:
return Constraint.Skip
return sum(model.nights[w.id, week, block] for w in single_workers) <= 1
self.model.night_shifts_max_st_constraint = Constraint(
[week for week in self.weeks],
[block for block in ["weekday", "weekend"]],
rule=nightShiftMaxSTRule,
)
# Enusre at least 1 ST4+ on a night shift
def nightShiftMinST4Rule(model, week, block):
single_workers = [w for w in self.workers if w.grade > 3]
if not single_workers:
return Constraint.Skip
return sum(model.nights[w.id, week, block] for w in single_workers) >= 1
self.model.night_shifts_min_st4_constraint = Constraint(
[week for week in self.weeks],
[block for block in ["weekday", "weekend"]],
rule=nightShiftMinST4Rule,
)
# Constraint (def of self.model.needed)
# for worker in self.workers:
# self.model.constraints.add(
# 10000 * self.model.needed[worker.id] >= sum(self.model.works[worker.id, week, day, shift] for week in weeks for day in days for shift in days_shifts[day])
# ) # if any self.model.works[worker, ·, ·] non-zero, self.model.needed[worker] must be one; else is zero to reduce the obj function
# 10000 is to remark, but 5 was enough since max of 40 hours yields max of 5 shifts, the maximum possible sum
# Constraint (def of self.model.no_pref)
# for worker in self.workers:
# for week in weeks:
# self.model.constraints.add(
# self.model.no_pref[worker.id] >= sum(self.model.works[worker.id, week, 'Sat', shift] for shift in days_shifts['Sat'])
# - sum(self.model.works[worker.id, week, 'Sun', shift] for shift in days_shifts['Sun'])
# ) # if not working on sunday but working saturday self.model.needed must be 1; else will be zero to reduce the obj function
# def balanceSiteShiftNumberRule(model, wid, fte_adj, shiftClass):
# # TODO: make helper to retrieve
# total_shifts = len(self.weeks) * len(shiftClass.shift_days) * shiftClass.self.workers_required # One person per site
# full_time_equivalent_joined = sum([self.full_time_equivalent_sites[i] for i in shiftClass.site])
# max_shifts = total_shifts / full_time_equivalent_joined * fte_adj + twilight_balance_offset
# min_shifts = total_shifts / full_time_equivalent_joined * fte_adj - twilight_balance_offset
# return inequality(min_shifts, sum(self.model.works[wid, week, day, shiftClass.name] for week, day in self.get_week_day_combinations()), max_shifts)
# #self.model.balance_site_shift_constraint = Constraint([(worker.id, worker.fte_adj, shift) for shift in self.GetShiftOptions() for worker in self.workers if worker.site in shift.site and shift.balance_by_site], rule=balanceSiteShiftNumberRule)
# # set a rule to even night shifts (could also be done in blocks, which may be quicker?)
# # takes into accound both LTFT, OOP and leaving the training scheme
# #total_night_shifts = self.rota_days_length * self.GetShiftByName("night").self.workers_required
# def nightShiftNumberRule(model, wid, fte_adj): # NOTE no longer used - see generic function below
# target_shifts = total_night_shifts / self.full_time_equivalent * fte_adj
# max_shifts = target_shifts + night_balance_offset
# min_shifts = target_shifts - night_balance_offset
# return inequality(min_shifts, sum(model.works[wid, week, day, "night"] for week, day in self.get_week_day_combinations()), max_shifts)
# #self.model.night_shifts_constraint = Constraint([(worker.id, worker.fte_adj) for worker in self.workers], rule=nightShiftNumberRule)
# twilight_constraints = {}
# Balance shifts
for worker in self.workers:
for shift in self.GetShiftOptions():
if (
worker.site in shift.site
): # Each site specfies which sites self.workers can fullfill it
# total_shifts = self.rota_days_length * shift.workers_required # One person per site
total_shifts = (
len(self.weeks)
* len(shift.shift_days)
* shift.workers_required
)
# if shift.balance_by_site:
full_time_equivalent_joined = sum(
[self.full_time_equivalent_sites[i] for i in shift.site]
)
target_shifts = (
total_shifts / full_time_equivalent_joined * worker.fte_adj
)
worker.shift_target_number[shift.name] = target_shifts
max_shifts = target_shifts + shift.balance_offset
min_shifts = target_shifts - shift.balance_offset
# print(max_shifts, min_shifts)
self.model.constraints.add(
inequality(
min_shifts,
sum(
self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations()
),
max_shifts,
)
)
# return inequality(min_shifts, sum(self.model.works[wid, week, day, shiftClass.name] for week, day in self.get_week_day_combinations()), max_shifts)
for worker in self.workers:
# Define shift_count_t1 and shift_count_t2 constraints for the object
# This bypassing the need for a quadratic solver
# t1-t2 is the target
# As the objective is to minimise t1+t2 and t1 and t2 are positive reals
# t1+t2 approximates the absolute target (which otherwise requires a quadratic solver)
self.model.constraints.add(
# self.model.shift_count_t3[worker.id] == abs(sum(self.model.shift_count[worker.id, shift.name] - worker.shift_target_number[shift.name] for shift in self.GetShiftOptions()))
self.model.shift_count_t1[worker.id]
- self.model.shift_count_t2[worker.id]
== sum(
(
self.model.shift_count[worker.id, shift.name]
- worker.shift_target_number[shift.name]
)
* shift.balance_weighting
for shift in self.GetShiftOptions()
)
)
for shift in self.GetShiftOptions():
self.model.constraints.add(
self.model.shift_count[worker.id, shift.name]
== sum(
self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations()
)
)
# self.model.constraints.add(
# #self.model.shift_count_t1[worker.id, shift.name]-self.model.shift_count_t2[worker.id, shift.name] == self.model.shift_count[worker.id, shift.name]
# self.model.shift_count_t1[worker.id, shift.name]-self.model.shift_count_t2[worker.id, shift.name] == self.model.shift_count[worker.id, shift.name] - worker.shift_target_number[shift.name]
# )
if worker.nwd:
for week, day, shift in self.get_all_shiftclass_combinations():
if not shift.rota_on_nwds and day in worker.nwd:
self.model.constraints.add(
0 == self.model.works[worker.id, week, day, shift.name]
)
# Constraint: rest between two shifts is of 12 hours (i.e., at least two shifts)
weeks_days = self.get_week_day_combinations()
for worker in self.workers:
if "nights" in self.get_shift_names():
for week_blocks in self.get_week_block_iterator(2):
# Prevent nights more than once every n weeks
self.model.constraints.add(
1
>= sum(
self.model.nights[worker.id, week, block]
for week in week_blocks
for block in ["weekday", "weekend"]
)
)
for week in self.weeks:
if "nights" in self.get_shift_names():
# Force nights to be assigned in blocks
self.model.constraints.add(
1
== sum(
self.model.nights[worker.id, week, block]
for block in self.night_blocks
)
)
# if night block is weekday make sure Mon - Thurs is assigned as nights
self.model.constraints.add(
self.model.nights[worker.id, week, "weekday"] * 4
== sum(
self.model.works[worker.id, week, day, "night"]
for day in days[:4]
)
)
self.model.constraints.add(
self.model.nights[worker.id, week, "weekend"] * 3
== sum(
self.model.works[worker.id, week, day, "night"]
for day in days[4:]
)
)
#
for n in range(len(weeks_days)):
week, day = weeks_days[n]
p1 = 1
if n > 0:
pweek, pday = weeks_days[n - 1]
else:
p1 = 0
pweek, pday = weeks_days[n]
n1 = 1
n2 = 1
try:
nweek, nday = weeks_days[n + 1]
except IndexError:
# print(worker.id, "shit")
nweek, nday = weeks_days[n]
n1 = 0
try:
n2week, n2day = weeks_days[n + 2]
except IndexError:
# print(worker.id, "shit2")
n2week, n2day = weeks_days[n]
n2 = 0
# print(week, day, nweek, nday, n2week, n2day, n1, n2)
# Unable to work (hard constraint not preference)
self.model.constraints.add(
self.model.available[worker.id, week, day]
>= sum(
self.model.works[worker.id, week, day, shift]
for shift in self.get_shift_namesByDay(day)
)
)
if "nights" in self.get_shift_names():
self.model.constraints.add(
self.model.available[worker.id, week, day]
>= self.model.works[worker.id, pweek, pday, "night"]
)
# single shift per day
self.model.constraints.add(
1
>= sum(
self.model.works[worker.id, week, day, shift]
for shift in self.get_shift_namesByDay(day)
)
)
## if working in evening, until next evening (note that after sunday comes next monday)
# self.model.constraints.add(
# 1 >= sum(self.model.works[worker.id, week, days[j], shift] for shift in ['evening', 'night']) +
# self.model.works[worker.id, nweek, days[(j + 1) % 7], 'truro_twilight']
# )
# if working a night ensure preceeding (1) or subsequent (2) shifts can only be nights
if "nights" in self.get_shift_names():
self.model.constraints.add(
1
>= self.model.works[worker.id, week, day, "night"]
+ sum(
n1 * self.model.works[worker.id, nweek, nday, shift]
for shift in self.get_shift_namesByDay(nday)
if shift != "night"
)
+ sum(
n2 * self.model.works[worker.id, n2week, n2day, shift]
for shift in self.get_shift_namesByDay(n2day)
if shift != "night"
)
+ sum(
p1 * self.model.works[worker.id, pweek, pday, shift]
for shift in self.get_shift_namesByDay(pday)
if shift != "night"
)
)
self.define_objectives()
def define_objectives(self):
# Define an objective function with model as input, to pass later
def obj_rule(m):
#c = len(workers)
#return 1
return sum(self.model.shift_count_t1[(worker.id)] + self.model.shift_count_t2[(worker.id)] for worker in self.workers)
#return sum(model.shift_count_t3[worker.id] for worker in workers)
# add objective function to the model. rule (pass function) or expr (pass expression directly)
self.model.obj = Objective(rule=obj_rule, sense=minimize)
def add_worker(self, worker: Worker):
"""Add a worker to the rota
Args:
worker (Worker):
"""
self.workers.append(worker)
def add_workers(self, workers: List):
"""Add multiple worker to the rota
Args:
workers (List(Worker)):
"""
self.workers.extend(workers)
def build_workers(self):
"""Process loaded workers
Must be called prior to attempting to solve
"""
self.workers = sorted(self.workers)
self.full_time_equivalent = sum([w.fte_adj for w in self.workers])
self.full_time_equivalent_sites = {}
for site in sites:
self.full_time_equivalent_sites[site] = sum(
[w.fte_adj for w in self.workers if w.site == site]
)
def add_shift(self, shift):
"""Add a shift to the collection
@@ -71,7 +578,7 @@ class ShiftCollection(object):
"""
self.shifts.append(shift)
self.BuildShifts()
self.BuildShift()
def add_shifts(self, *shifts: SingleShift):
"""Add multiple shifts
@@ -80,9 +587,9 @@ class ShiftCollection(object):
None
"""
self.shifts.extend(shifts)
self.BuildShifts()
self.BuildShift()
def GetShiftNamesByDay(self, day: DayStr):
def get_shift_namesByDay(self, day: DayStr):
"""Returns the shifts required for a specific day
Returns:
@@ -110,7 +617,7 @@ class ShiftCollection(object):
"""
return self.shifts_by_name[name].length
def BuildShifts(self):
def BuildShift(self):
""" """
self.shifts_by_name = {}
self.shift_names = [] # type: List[ShiftName]
@@ -140,7 +647,7 @@ class ShiftCollection(object):
self.day_shift_product.append((day, s.name))
self.day_shiftclass_product.append((day, s))
def GetAllDayShiftsAsNames(self):
def GetAllDayShiftAsNames(self):
"""Returns a list of all required day / shift combinations
Returns:
@@ -148,7 +655,7 @@ class ShiftCollection(object):
"""
return self.day_shift_product
def GetAllDayShiftsAsClass(self) -> List[Tuple[DayStr, SingleShift]]:
def get_day_shiftclass_products(self) -> List[Tuple[DayStr, SingleShift]]:
"""Returns a list of all required day / shift combinations
Returns:
@@ -156,7 +663,7 @@ class ShiftCollection(object):
"""
return self.day_shiftclass_product
def GetAllShiftsAsNames(self) -> List[Tuple[WeekInt, DayStr, ShiftName]]:
def GetAllShiftAsNames(self) -> List[Tuple[WeekInt, DayStr, ShiftName]]:
"""Returns a list of all possible week / day / shift combinations
Returns:
@@ -164,7 +671,7 @@ class ShiftCollection(object):
"""
return self.week_day_shift_product
def GetAllShiftsAsClass(self) -> List[Tuple[WeekInt, DayStr, SingleShift]]:
def get_all_shiftclass_combinations(self) -> List[Tuple[WeekInt, DayStr, SingleShift]]:
"""Returns a list of all possible week / day / shift combinations
Returns:
@@ -172,7 +679,7 @@ class ShiftCollection(object):
"""
return self.week_day_shiftclass_product
def GetAllWeeksDays(self) -> list:
def get_week_day_combinations(self) -> list:
"""Returns a list of all week / day tuple combinations
Returns:
@@ -189,7 +696,7 @@ class ShiftCollection(object):
"""
return self.shifts
def GetShiftNames(self) -> List[ShiftName]:
def get_shift_names(self) -> List[ShiftName]:
"""Returns a list of all the registered shift names
Returns:
@@ -197,7 +704,7 @@ class ShiftCollection(object):
""" """ """
return self.shift_names
def GetWeeksInBlocks(self, block_length):
def get_week_block_iterator(self, block_length):
"""Gets a two dimensional list of week blocks in specified length
e.g. block_length = 4
@@ -218,7 +725,7 @@ class ShiftCollection(object):
blocks.append(self.weeks[i : i + block_length])
return blocks
def GetRequiredShiftsWorkersAndSites(self):
def get_required_workers_and_site_combinations(self):
"""Returns a list of all possible shifts, the workers required and site
Returns:
@@ -227,11 +734,13 @@ class ShiftCollection(object):
l = []
for week, day, shift in self.week_day_shiftclass_product:
if day in shift.shift_days:
l.append((week, day, shift.name, shift.workers_required, shift.site))
l.append(
(week, day, shift.name, shift.workers_required, shift.site)
)
return l
def GetNotRequiredShifts(self):
def get_not_required_shifts(self):
"""Returns a set of all possible shifts combinations that are not required
Includes those on days
@@ -246,4 +755,4 @@ class ShiftCollection(object):
for shift in self.shifts:
l.add((week, day, shift))
return l - set(self.GetAllShiftsAsClass())
return l - set(self.get_all_shiftclass_combinations())
+30 -23
View File
@@ -1,11 +1,13 @@
import datetime
from collections import defaultdict
from shifts import SingleShift, ShiftCollection, days, sites
#from shifts import RotaBuilder, days, sites
class Worker:
def __init__(self, Shifts, id, name, site, grade, fte=100, nwd=None, end_date=None, oop=None):
def __init__(
self, Rota, id, name, site, grade, fte=100, nwd=None, end_date=None, oop=None
):
self.id = id
self.name = name
self.site = site
@@ -16,48 +18,53 @@ class Worker:
self.shift_target_number = defaultdict(int)
days_to_work = Shifts.rota_days_length
days_to_work = Rota.rota_days_length
if end_date is None:
self.end_date = None
else:
self.end_date = datetime.datetime.strptime(end_date, '%Y/%m/%d').date()
self.end_date = datetime.datetime.strptime(end_date, "%Y/%m/%d").date()
if self.end_date > Shifts.rota_end_date:
self.end_date = Shifts.rota_end_date
if self.end_date > Rota.rota_end_date:
self.end_date = Rota.rota_end_date
else:
days_to_work = (self.end_date - Shifts.start_date).days
days_to_work = (self.end_date - Rota.start_date).days
# add unavalabilities
for weeks_days in Shifts.weeks_days_product[days_to_work:]:
for weeks_days in Rota.weeks_days_product[days_to_work:]:
week, day = weeks_days
Shifts.unavailable_to_work.add((self.id, week, day))
Rota.unavailable_to_work.add((self.id, week, day))
if oop is not None:
start_oop, end_oop = oop
start_oop_date = datetime.datetime.strptime(start_oop, '%Y/%m/%d').date()
end_oop_date = datetime.datetime.strptime(end_oop, '%Y/%m/%d').date()
start_oop_date = datetime.datetime.strptime(start_oop, "%Y/%m/%d").date()
end_oop_date = datetime.datetime.strptime(end_oop, "%Y/%m/%d").date()
if end_oop_date > Rota.rota_end_date:
end_oop_date = Rota.rota_end_date
if end_oop_date > Shifts.rota_end_date:
end_oop_date = Shifts.rota_end_date
oop_length = (end_oop_date - start_oop_date).days
days_to_work = days_to_work - oop_length
days_until_oop = (start_oop_date - Shifts.start_date).days
days_until_oop = (start_oop_date - Rota.start_date).days
for weeks_days in Shifts.weeks_days_product[days_until_oop:days_until_oop+oop_length]:
for weeks_days in Rota.weeks_days_product[
days_until_oop : days_until_oop + oop_length
]:
week, day = weeks_days
Shifts.unavailable_to_work.add((self.id, week, day))
Rota.unavailable_to_work.add((self.id, week, day))
self.proportion_rota_to_work = days_to_work / Shifts.rota_days_length
self.proportion_rota_to_work = days_to_work / Rota.rota_days_length
# We had to adjust the full time equivalent for people who CCT / leave the rota early
self.fte_adj = self.fte * self.proportion_rota_to_work
def __lt__(self, other):
return (self.site, self.grade, self.name) < (other.site, other.grade, other.name)
return (self.site, self.grade, self.name) < (
other.site,
other.grade,
other.name,
)
def get_details(self):
return "{} {} {}".format(self.id, self.site[0], self.grade)
return "{} {} {}".format(self.id, self.site[0], self.grade)