Coverage for backend/app/models.py: 99%
147 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-22 15:38 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-22 15:38 +0000
1"""
2This module defines the database table models for the application using SQLAlchemy ORM. Each class represents a table in
3the database, with its fields defining the table's columns and relationships. The module utilizes a `CommonBase` class
4to provide a shared structure for all models, including common attributes like `id`, `created_at`, and `created_by`."""
6import re
8from sqlalchemy import (
9 Column,
10 Integer,
11 String,
12 ForeignKey,
13 Float,
14 Boolean,
15 TIMESTAMP,
16 text,
17 CheckConstraint,
18 Table,
19 func,
20)
21from sqlalchemy.ext.declarative import declared_attr
22from sqlalchemy.ext.hybrid import hybrid_property
23from sqlalchemy.orm import relationship
24from sqlalchemy.sql import expression
26from app.database import Base
27from app.config import settings
29# ------------------------------------------------------ MAPPINGS ------------------------------------------------------
32job_keyword_mapping = Table(
33 "job_keyword_mapping",
34 Base.metadata,
35 Column("job_id", Integer, ForeignKey("job.id", ondelete="CASCADE"), primary_key=True),
36 Column("keyword_id", Integer, ForeignKey("keyword.id", ondelete="CASCADE"), primary_key=True),
37)
39interview_interviewer_mapping = Table(
40 "interview_interviewer_mapping",
41 Base.metadata,
42 Column("interview_id", Integer, ForeignKey("interview.id", ondelete="CASCADE"), primary_key=True),
43 Column("person_id", Integer, ForeignKey("person.id", ondelete="CASCADE"), primary_key=True),
44)
46job_contact_mapping = Table(
47 "job_contact_mapping",
48 Base.metadata,
49 Column("job_id", Integer, ForeignKey("job.id", ondelete="CASCADE"), primary_key=True),
50 Column("person_id", Integer, ForeignKey("person.id", ondelete="CASCADE"), primary_key=True),
51)
54# -------------------------------------------------------- BASES -------------------------------------------------------
57class CommonBase(object):
58 """A base class that contains common attributes shared by all tables.
60 Attributes:
61 -----------
62 - `id` (int): Primary key of the record. Automatically populated upon creation.
63 - `created_at` (datetime): The timestamp of when the record was created. Automatically populated upon creation.
64 - `modified_at` (datetime): The timestamp of when the record was modified. Automatically updated upon updates."""
66 # noinspection PyMethodParameters
67 @declared_attr
68 def __tablename__(cls) -> str:
69 """Return the class name as table name e.g. JobApplication -> job_application"""
71 name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", cls.__name__)
72 return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
74 id = Column(Integer, primary_key=True, nullable=False)
75 created_at = Column(TIMESTAMP(timezone=True), server_default=text("now()"), nullable=False)
76 modified_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
79class Owned(CommonBase):
80 """A base class that contains common attributes shared by tables which entries have an owner.
82 Attributes:
83 -----------
84 - `owner_id` (int): Foreign key linking the record to the user table."""
86 owner_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
89# --------------------------------------------------------- APP --------------------------------------------------------
92class Setting(CommonBase, Base):
93 """Represents the application settings
95 Attributes:
96 -----------
97 - `name` (str): The name of the setting.
98 - `value` (float): The value of the setting.
99 - `description` (str): A description of the setting."""
101 name = Column(String, nullable=False, unique=True)
102 value = Column(String, nullable=False)
103 description = Column(String, nullable=True)
106class User(CommonBase, Base):
107 """Represents users of the application.
109 Attributes:
110 -----------
111 - `password` (str): Encrypted password for authentication.
112 - `email` (str): User's email address (must be unique).
113 - `theme` (str): The theme of the application.
114 - `is_admin` (bool): Indicates whether the user is an administrator.
115 - `last_login` (datetime): The timestamp of the last login.
116 - `chase_threshold` (int): The threshold for chasing jobs in the dashboard.
117 - `deadline_threshold` (int): The threshold for deadlines in the dashboard.
118 - `update_limit` (int): Max number updates displayed in the dashboard."""
120 password = Column(String, nullable=False)
121 email = Column(String, nullable=False, unique=True)
122 theme = Column(String, nullable=False, server_default="mixed-berry")
123 is_admin = Column(Boolean, nullable=False, server_default=expression.false())
124 last_login = Column(TIMESTAMP(timezone=True), nullable=True)
125 chase_threshold = Column(Integer, nullable=False, server_default="30")
126 deadline_threshold = Column(Integer, nullable=False, server_default="30")
127 update_limit = Column(Integer, nullable=False, server_default="10")
129 __table_args__ = (
130 CheckConstraint(f"length(password) >= {settings.min_password_length}", name="minimum_password_length"),
131 )
134# -------------------------------------------------------- DATA --------------------------------------------------------
137class Keyword(Owned, Base):
138 """Represents keywords associated with job postings.
140 Attributes:
141 -----------
142 - `name` (str): The keyword name.
144 Relationships:
145 --------------
146 - `jobs` (list of Job): List of jobs associated with the keyword."""
148 name = Column(String, nullable=False)
150 # Relationships
151 jobs = relationship("Job", secondary=job_keyword_mapping, back_populates="keywords")
154class Aggregator(Owned, Base):
155 """Represents an aggregator website (e.g. LinkedIn, Indeed).
157 Attributes:
158 -----------
159 - `name` (str): The website's name.
160 - `url` (str): The website's URL.
162 Relationships:
163 --------------
164 - `jobs` (list of Job): List of jobs associated with the aggregator.
165 - `job_applications` (list of Job): List of jobs associated with the aggregator."""
167 name = Column(String, nullable=False)
168 url = Column(String, nullable=False)
170 jobs = relationship("Job", foreign_keys="Job.source_id", back_populates="source")
171 job_applications = relationship(
172 "Job", foreign_keys="Job.application_aggregator_id", back_populates="application_aggregator"
173 )
176class Company(Owned, Base):
177 """Represents a company or organisation.
179 Attributes:
180 -----------
181 - `name` (str): Name of the company.
182 - `description` (str, optional): Description or details about the company.
183 - `url` (str, optional): Web link to the company's website.
185 Relationships:
186 --------------
187 - `jobs` (list of Job): List of jobs associated with the company.
188 - `persons` (list of Person): List of people linked to the company."""
190 name = Column(String, nullable=False)
191 description = Column(String, nullable=True)
192 url = Column(String, nullable=True)
194 # Relationships
195 jobs = relationship("Job", back_populates="company")
196 persons = relationship("Person", back_populates="company")
199class Location(Owned, Base):
200 """Represents geographical locations.
202 Attributes:
203 -----------
204 - `postcode` (str, optional): Postcode of the location.
205 - `city` (str, optional): City of the location.
206 - `country` (str, optional): Country where the location resides.
207 - `name` (str): Computed property combining city, country, and postcode
209 Relationships:
210 --------------
211 - `jobs` (list of Job): List of jobs associated with the location.
212 - `interviews` (list of Interview): List of interviews associated with the location."""
214 postcode = Column(String, nullable=True)
215 city = Column(String, nullable=True)
216 country = Column(String, nullable=True)
218 # Relationships
219 jobs = relationship("Job", back_populates="location")
220 interviews = relationship("Interview", back_populates="location")
222 @hybrid_property
223 def name(self) -> str:
224 """Computed property that combines city, country, and postcode into a readable location name"""
226 parts = []
227 if self.city:
228 parts.append(self.city)
229 if self.country:
230 parts.append(self.country)
231 if self.postcode:
232 parts.append(self.postcode)
234 return ", ".join(parts)
236 __table_args__ = (
237 CheckConstraint(
238 "postcode IS NOT NULL OR city IS NOT NULL OR country IS NOT NULL",
239 name=f"location_data_required",
240 ),
241 )
244class File(Owned, Base):
245 """Represents files uploaded by the users.
247 Attributes:
248 -----------
249 - `filename` (str): Name of the file.
250 - `content` (bytes): Content of the file.
251 - `type` (str): MIME type of the file.
252 - `size` (int): Size of the file in bytes."""
254 filename = Column(String, nullable=False)
255 content = Column(String, nullable=False)
256 type = Column(String, nullable=False)
257 size = Column(Integer, nullable=False)
260class Person(Owned, Base):
261 """Represents a person
263 Attributes:
264 -----------
265 - `first_name` (str): First name of the person.
266 - `last_name` (str): Last name of the person.
267 - `email` (str, optional): Email address of the person.
268 - `phone` (str, optional): Phone number of the person.
269 - `role` (str, optional): Role or position held by the person within the company.
270 - `linkedin_url` (str, optional): LinkedIn profile URL of the person.
271 - `name` (str): Computed property combining first and last name.
272 - `name_company` (str): Computed property combining first name, last name, and company name.
274 Foreign keys:
275 -------------
276 - `company_id` (int): Foreign key linking the person to a company.
278 Relationships:
279 --------------
280 - `company` (Company): Relationship to access the associated company.
281 - `interviews` (list of Interview): List of interviews performed by the person within the company.
282 - `jobs` (list of Job): List of jobs linked to the person within the company."""
284 first_name = Column(String, nullable=False)
285 last_name = Column(String, nullable=False)
286 email = Column(String, nullable=True)
287 phone = Column(String, nullable=True)
288 role = Column(String, nullable=True)
289 linkedin_url = Column(String, nullable=True)
291 # Foreign keys
292 company_id = Column(Integer, ForeignKey("company.id", ondelete="SET NULL"), nullable=True, index=True)
294 # Relationships
295 company = relationship("Company", back_populates="persons")
296 interviews = relationship("Interview", secondary=interview_interviewer_mapping, back_populates="interviewers")
297 jobs = relationship("Job", secondary=job_contact_mapping, back_populates="contacts")
299 @hybrid_property
300 def name(self) -> str:
301 """Computed property that combines the first and last name"""
303 return f"{self.first_name} {self.last_name}"
305 @hybrid_property
306 def name_company(self) -> str:
307 """Computed property that combines the first name, last name, and the company name"""
309 if self.company:
310 return f"{self.first_name} {self.last_name} - {self.company.name}"
311 else:
312 return self.name
315class Job(Owned, Base):
316 """Represents job postings within the application.
318 Attributes:
319 -----------
320 - `title` (str): The job title.
321 - `description` (str, optional): Description or details about the job.
322 - `salary_min` (float, optional): Minimum salary offered for the job (in GBP).
323 - `salary_max` (float, optional): Maximum salary offered for the job (in GBP).
324 - `url` (str, optional): Web link to the job posting.
325 - `personal_rating` (int, optional): Personalised rating given to the job.
326 - `note` (str, optional): Additional note about the job posting.
327 - `deadline` (datetime, optional): Deadline for the job application.
328 - `attendance_type` (str, optional): Type of attendance offered for the job (on-site, remote, hybrid).
329 - `name` (str): Computed property combining the job title and company name.
331 Foreign keys:
332 -------------
333 - `company_id` (int): Identifier for the company offering the job.
334 - `location_id` (int, optional): Identifier for the geographical location where the job is located.
335 - `duplicate_id` (int, optional): Identifier for a duplicate job posting.
336 - `source_id` (int, optional): Identifier for the aggregator website where the job was posted.
338 Relationships:
339 --------------
340 - `company` (Company): Company object associated with the job posting.
341 - `location` (Location): Location object associated with the job posting.
342 - `keywords` (list of Keyword): List of keywords associated with the job posting.
343 - `contacts` (list of Person): List of people linked to the company that may be interested in the job posting.
344 - `source` (Aggregator): Source of the job posting (e.g. LinkedIn, Indeed, etc.)."""
346 title = Column(String, nullable=False)
347 description = Column(String, nullable=True)
348 salary_min = Column(Float, nullable=True)
349 salary_max = Column(Float, nullable=True)
350 url = Column(String, nullable=True)
351 personal_rating = Column(Integer, nullable=True)
352 note = Column(String, nullable=True)
353 deadline = Column(TIMESTAMP(timezone=True), nullable=True)
354 attendance_type = Column(String, nullable=True)
355 application_date = Column(TIMESTAMP(timezone=True), nullable=True)
356 application_url = Column(String, nullable=True)
357 application_status = Column(String, nullable=True)
358 applied_via = Column(String, nullable=True)
359 application_note = Column(String, nullable=True)
361 # Foreign keys
362 company_id = Column(Integer, ForeignKey("company.id", ondelete="SET NULL"), nullable=True, index=True)
363 location_id = Column(Integer, ForeignKey("location.id", ondelete="SET NULL"), nullable=True, index=True)
364 duplicate_id = Column(Integer, ForeignKey("job.id", ondelete="SET NULL"), nullable=True, index=True)
365 source_id = Column(Integer, ForeignKey("aggregator.id", ondelete="SET NULL"), nullable=True, index=True)
366 application_aggregator_id = Column(
367 Integer, ForeignKey("aggregator.id", ondelete="SET NULL"), nullable=True, index=True
368 )
369 cv_id = Column(Integer, ForeignKey("file.id", ondelete="SET NULL"), nullable=True, index=True)
370 cover_letter_id = Column(Integer, ForeignKey("file.id", ondelete="SET NULL"), nullable=True, index=True)
372 # Relationships
373 company = relationship("Company", back_populates="jobs")
374 location = relationship("Location", back_populates="jobs")
375 keywords = relationship("Keyword", secondary=job_keyword_mapping, back_populates="jobs", lazy="selectin")
376 contacts = relationship("Person", secondary=job_contact_mapping, back_populates="jobs", lazy="selectin")
377 source = relationship("Aggregator", foreign_keys=[source_id], back_populates="jobs")
378 interviews = relationship("Interview", back_populates="job")
379 updates = relationship("JobApplicationUpdate", back_populates="job")
380 application_aggregator = relationship(
381 "Aggregator", foreign_keys=[application_aggregator_id], back_populates="job_applications"
382 )
383 application_cv = relationship("File", foreign_keys=[cv_id], lazy="select")
384 application_cover_letter = relationship("File", foreign_keys=[cover_letter_id], lazy="select")
386 @hybrid_property
387 def name(self) -> str | Column[str]:
388 """Computed property that combines the job title and company name"""
390 if hasattr(self, "company") and self.title and self.company and self.company.name:
391 return f"{self.title} - {self.company.name}"
392 elif self.title:
393 return self.title
394 else:
395 return "Unknown Job"
397 __table_args__ = (
398 CheckConstraint("personal_rating >= 1 AND personal_rating <= 5", name=f"valid_rating_range"),
399 CheckConstraint("salary_min <= salary_max", name=f"valid_salary_range"),
400 CheckConstraint("attendance_type IN ('on-site', 'remote', 'hybrid')", name="valid_attendance_type_values"),
401 )
404class Interview(Owned, Base):
405 """Represents interviews for job applications.
407 Attributes:
408 -----------
409 - `date` (datetime): The date and time of the interview.
410 - `type` (str): Type of the interview (HR, technical, management, ...)
411 - `note` (str, optional): Additional notes or comments about the interview.
413 Foreign keys:
414 -------------
415 - `location_id` (int): Identifier for the location of the interview.
416 - `job_id` (int): Identifier for the job application associated with the interview.
418 Relationships:
419 --------------
420 - `job` (Job): Job object related to the interview.
421 - `interviewers` (list of Person): List of people who participated in the interview.
422 - `location` (Location): Location object related to the interview."""
424 date = Column(TIMESTAMP(timezone=True), server_default=text("now()"), nullable=False)
425 type = Column(String, nullable=False)
426 note = Column(String, nullable=True)
427 attendance_type = Column(String, nullable=True)
429 # Foreign keys
430 location_id = Column(Integer, ForeignKey("location.id", ondelete="SET NULL"), nullable=True, index=True)
431 job_id = Column(Integer, ForeignKey("job.id", ondelete="CASCADE"), nullable=False, index=True)
433 # Relationships
434 location = relationship("Location", back_populates="interviews")
435 job = relationship("Job", back_populates="interviews")
436 interviewers = relationship("Person", secondary=interview_interviewer_mapping, back_populates="interviews")
438 __table_args__ = (CheckConstraint("attendance_type IN ('on-site', 'remote')", name="valid_attendance_type_values"),)
441class JobApplicationUpdate(Owned, Base):
442 """Represents an update to a job application.
444 Attributes:
445 -----------
446 - `date` (datetime): The date and time of the update.
447 - `note` (str, optional): Additional notes or comments about the update.
448 - `type` (str): The type of the update (received, sent).
450 Foreign keys:
451 -------------
452 - job_id (int): Identifier for the job application associated with the update.
454 Relationships:
455 --------------
456 - `job` (Job): Job object related to the update."""
458 date = Column(TIMESTAMP(timezone=True), server_default=text("now()"), nullable=False)
459 note = Column(String, nullable=True)
460 type = Column(String, nullable=False)
462 # Foreign keys
463 job_id = Column(Integer, ForeignKey("job.id", ondelete="CASCADE"), nullable=False)
465 # Relationships
466 job = relationship("Job", back_populates="updates")