diff --git a/app/config/contributors.yaml b/app/config/contributors.yaml index d26cdd366..2a47673ca 100755 --- a/app/config/contributors.yaml +++ b/app/config/contributors.yaml @@ -59,4 +59,7 @@ contributors: - name: "Oluwagbayi Makinde" username: "makindeo" year: 2024 + - name: "Bhushan Sah " + username: "sahb" + year: 2029 \ No newline at end of file diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 0a2f21e4b..88651a11f 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -16,6 +16,9 @@ from app.login_manager import require_login, logout from app.logic.getTableData import getDatatableData from app.logic.banner import Banner +from app.models.positionHistory import PositionHistory +from app.logic.getPositions import getActivePositions + @main_bp.route('/logout', methods=['GET']) def triggerLogout(): @@ -57,15 +60,18 @@ def departmentPortal(org=None,account=None): dept = None - if g.currentUser.isLaborAdmin: departments = list(Department.select().order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) else: departments = list(getDepartmentsForSupervisor(g.currentUser).order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) + positionsList, posURL = getActivePositions(dept) + return render_template('main/departmentPortal.html', departments = departments, - department = dept) + department = dept, + positions = positionsList, + posURL = posURL) @main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) def addUserToDept(): diff --git a/app/logic/getPositions.py b/app/logic/getPositions.py new file mode 100644 index 000000000..76cd87459 --- /dev/null +++ b/app/logic/getPositions.py @@ -0,0 +1,45 @@ +from app.models.positionHistory import PositionHistory + +def getActivePositions(dept): + """ + Returns a list of active positions for a given department, along with their corresponding position codes. + The function filters out duplicate position codes within the same department and ensures that a position code can only be Active in ONE place at a time. + """ + if not dept: + return [], [] + + positions = list(PositionHistory.select() + .where(PositionHistory.department == dept, PositionHistory.status == "Active") + .order_by(PositionHistory.positionTitle.asc())) + + positionsList = [] + posURL = [] + seenCodes = set() + # Iterate through the positions and filter out duplicates based on positionCode and department + for pos in positions: + code = pos.positionCode + + # Same dept returned two Active rows with the same code (shouldn't normally happen, but guards against bad data) -> skip the duplicate + if code in seenCodes: + continue + + # Enforces: A positionCode can only be Active in ONE place at a time, regardless of department or revisionDate. + # If an earlier-created Active row with this code exists in a DIFFERENT department, this one loses. + claimedElsewhere = (PositionHistory + .select() + .where( + PositionHistory.positionCode == code, + PositionHistory.status == "Active", + PositionHistory.department != dept, + PositionHistory.id < pos.id # Uses PeeWee's auto-incrementing ID to determine which row was created first. + ) + .exists()) + + if claimedElsewhere: + continue + + seenCodes.add(code) # Mark this code as seen for the current department to avoid duplicates + positionsList.append(pos.positionTitle + ": " + "(WLS " + str(pos.wls) + ")") + posURL.append(str(pos.positionCode)) + + return positionsList, posURL \ No newline at end of file diff --git a/app/models/positionHistory.py b/app/models/positionHistory.py index 514670759..fd129c764 100644 --- a/app/models/positionHistory.py +++ b/app/models/positionHistory.py @@ -2,6 +2,7 @@ from app.models.department import Department class PositionHistory(baseModel): + positionTitle = CharField() positionCode = CharField() department = ForeignKeyField(Department) status = CharField() @@ -10,5 +11,5 @@ class PositionHistory(baseModel): description = TextField(default=None) class Meta: - indexes = ( (('positionCode', 'revisionDate', 'status'), True), ) + indexes = ( (('positionCode', 'revisionDate', 'status'), False), ) diff --git a/app/static/css/base.css b/app/static/css/base.css index 940f8d6e5..812d3c89b 100755 --- a/app/static/css/base.css +++ b/app/static/css/base.css @@ -250,6 +250,7 @@ a { padding: 0.5rem 1rem; background-color: rgba(0, 0, 0, 0.03); border-top: 1px solid rgba(0, 0, 0, 0.125); + margin-top: auto; } .card-footer:last-child { @@ -264,3 +265,29 @@ a { border-top-left-radius: calc(0.25rem - 1px); border-top-right-radius: calc(0.25rem - 1px); } +.site-footer { + position: fixed; + left: 280px; + right: 0; + bottom: 0; + + background-color: #e9ebec; + color: #1e2433; + + padding: 8px 15px; + line-height: 1.4; + box-sizing: border-box; + z-index: 101; + +} + +.sidebar-push { + padding-bottom: 85px; +} + +@media (max-width: 991px) { + .site-footer { + left: 0; + width: 100%; + } +} diff --git a/app/static/css/contributors.css b/app/static/css/contributors.css index 0fe0601b0..2f54c122e 100755 --- a/app/static/css/contributors.css +++ b/app/static/css/contributors.css @@ -3,7 +3,6 @@ color: #ffffff; padding: 7px 30px; margin-top: 100px; - margin-right: 250px; } .container { top: -75px; diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css new file mode 100644 index 000000000..b563c5054 --- /dev/null +++ b/app/static/css/departmentPortal.css @@ -0,0 +1,31 @@ +.card { + border-radius: 1rem; + overflow: hidden; + width: 100%; + height: 100%; + min-width: 100%; + box-shadow: 2px 2px 4px rgba(100, 100, 100, 0.26); +} +.bi-suitcase-lg-fill { /* Bootstrap Icon */ + border: 1px solid #c0c0c0; + border-radius: 8px; + padding: 3px 3.5px 1.5px 3.5px; + font-size: 3rem; + color:#6e6e6e; +} +.card-group { + gap: 1rem; +} +.form-group { + width: 50%; + margin-left: auto ; + margin-right:auto; +} +.card-body { + padding: 1rem 1rem; + min-width: 100% +} +.row { + display: flex; + flex-wrap: wrap; +} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 4d6c2a79f..4b9825368 100755 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -66,10 +66,20 @@
{% block footer %} - -{% endblock %} + +{% endblock %} {% endblock %} diff --git a/app/templates/main/contributors.html b/app/templates/main/contributors.html index ffdfe4391..af33209dc 100755 --- a/app/templates/main/contributors.html +++ b/app/templates/main/contributors.html @@ -23,9 +23,9 @@

Contributors

{% endfor %} -
- -
+
+
+
diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 8ee01838b..91326fb94 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -3,6 +3,7 @@ {% block scripts %} {{super()}} + {% endblock %} {% block app_content %} @@ -24,4 +25,70 @@

{% if department %} {{department.DEPT_NAME}} Portal {% e + + {% if department %} +
+
+ +
+

Insert Allocations Card Here

+ +
+ +
+ +
+
+ +

Insert Members Card Here

+ +
+ +
+ +
+
+
+
+
+ +
+
+

Positions

+
+ +
+
    + {% if positions == [] %} +
  • +

    No active positions in this department

    +
  • + {% else %} + {% for p in positions[:7] %} +
  • + {{ p }} +
  • + {% endfor %} + {% if (positions | length) > 7 %} +

and {{ (positions | length) - 7}} more ...

+ {% endif %} + {% endif %} + + +
+ +
+
+
+ +{% endif %} + {% endblock %} diff --git a/database/demo_data.py b/database/demo_data.py index de6047624..1311a7487 100644 --- a/database/demo_data.py +++ b/database/demo_data.py @@ -750,50 +750,119 @@ # Position History ############################# -positionHistory = [ +positionhistory = [ { + "positionTitle": "Student Programmer", "positionCode": "S61407", "status": "Active", "wls": 1, - "revisionDate": f"2025-07-01", + "revisionDate": f"2026-07-01", "description": "", "department": 1 }, { - + "positionTitle": "Research Associate", "positionCode": "S61408", "status": "Active", "wls": 2, - "revisionDate": f"2025-09-01", + "revisionDate": f"2026-09-01", "description": "", "department": 1 }, { + "positionTitle": "Labor Workers", "positionCode": "S61409", "status": "Active", "wls": 3, - "revisionDate": f"2025-07-01", + "revisionDate": f"2026-07-01", "description": "", "department": 1 }, { + "positionTitle": "Teaching Associate", "positionCode": "S61411", "status": "Active", "wls":3, - "revisionDate" : f"2025-01-01", + "revisionDate" : f"2026-01-01", "description": "", "department" : 1 - }, - { + }, + { + "positionTitle": "Teaching Associate", "positionCode": "S61410", "status": "Inactive", "wls":2, - "revisionDate" : f"2025-01-01", + "revisionDate" : f"2026-01-01", + "description": "", + "department" : 3 + }, + { + "positionTitle": "Teaching Associate", + "positionCode": "S61410", + "status": "Active", + "wls":2, + "revisionDate" : f"2026-03-29", + "description": "", + "department" : 3 + }, + { + "positionTitle": "DUMMY POSITION", + "positionCode": "S12345", + "status": "Active", + "wls":3, + "revisionDate" : f"2026-01-23", "description": "", "department" : 1 - }, + }, + { + "positionTitle": "Junior Data Analyst", + "positionCode": "S39568", + "status": "Active", + "wls":4, + "revisionDate" : f"2026-01-31", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Student Manager", + "positionCode": "S74933", + "status": "Active", + "wls":5, + "revisionDate" : f"2026-04-01", + "description": "", + "department" : 1 + }, + { + "positionTitle": "IT Technician", + "positionCode": "S94932", + "status": "Active", + "wls":6, + "revisionDate" : f"2026-05-03", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Human code generator", + "positionCode": "S22222", + "status": "Active", + "wls":1, + "revisionDate" : f"2026-05-03", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Senior Software Engineer", + "positionCode": "S00000", + "status": "Active", + "wls":6, + "revisionDate" : f"2026-05-03", + "description": "", + "department" : 1 + } + + ] -PositionHistory.insert_many(positionHistory).on_conflict_replace().execute() +PositionHistory.insert_many(positionhistory).on_conflict_replace().execute() print(" * position history added") \ No newline at end of file diff --git a/setup.sh b/setup.sh index 895e43b98..c4e43d652 100755 --- a/setup.sh +++ b/setup.sh @@ -39,3 +39,13 @@ export FLASK_APP=app.py # app entry point export APP_ENV=development # default export FLASK_RUN_PORT=8080 # For consistency (python app.py vs flask run) export FLASK_RUN_HOST=0.0.0.0 # To allow external routing to the application + +# Fix symbolic link inside database directory +if ! [ -L "database/app" ]; then + echo "Fixing symlink to app in database directory" + cd database + rm app + ln -s ../app + cd .. +fi + diff --git a/tests/code/test_getPositions.py b/tests/code/test_getPositions.py new file mode 100644 index 000000000..6897c1d8c --- /dev/null +++ b/tests/code/test_getPositions.py @@ -0,0 +1,385 @@ +import pytest +from app.models import mainDB +from app.models.department import Department +from app.models.positionHistory import PositionHistory +from app.logic.getPositions import getActivePositions + +@pytest.mark.integration +def test_getActivePositions(): + """ + Test to check if the getActivePositions function in getPositions.py correctly retrieves active positions for a single department. + """ + with mainDB.atomic() as transaction: + dept1 = Department.create(departmentID=100, DEPT_NAME="Computer Science", ACCOUNT="6740", ORG="2114", departmentCompliance=True, isActive=True) + + position1 = PositionHistory.create(positionTitle="Teaching Assistant", + positionCode="S34512", + department=dept1, + status="Active", + wls=4, + revisionDate="2023-01-01", + description="") + + position2 = PositionHistory.create(positionTitle="Research Assistant", + positionCode="S34513", + department=dept1, + status="Inactive", + wls=3, + revisionDate="2023-01-01", + description="") + + position3 = PositionHistory.create(positionTitle="Lab Assistant", + positionCode="S34514", + department=dept1, + status="Active", + wls=2, + revisionDate="2026-01-01", + description="") + + position4 = PositionHistory.create(positionTitle="Intern", + positionCode="S34515", + department=dept1, + status="Active", + wls=1, + revisionDate="2023-01-01", + description="") + + positionsList, posURL = getActivePositions(dept1) + + # Check that only active positions are returned + assert len(positionsList) == 3 + assert len(posURL) == 3 + + # Check if position list is returned in alphabetical order + assert positionsList[0] == "Intern: (WLS 1)" + assert positionsList[1] == "Lab Assistant: (WLS 2)" + assert positionsList[2] == "Teaching Assistant: (WLS 4)" + + # Check if the inactive position is not included in the results + assert "Research Assistant: (WLS 3)" not in positionsList + + # Check if posURL contains the correct position codes in the same order as positionsList + assert posURL[0] == "S34515" # Intern + assert posURL[1] == "S34514" # Lab Assistant + assert posURL[2] == "S34512" # Teaching Assistant + + # Check if the inactive position code is not included in posURL + assert "S34513" not in posURL # Research Assistant + + transaction.rollback() + +@pytest.mark.integration +def test_checkNoPositionInDepartment(): + """ + Checks the behavior of getActivePositions when there are no positions in the department. + It should return empty lists for both positionsList and posURL, indicating that no positions are available for the department. + """ + with mainDB.atomic() as transaction: + dept5 = Department.create(departmentID=101, DEPT_NAME="Mathematics", ACCOUNT="6741", ORG="2115", departmentCompliance=True, isActive=True) + + positionsList, posURL = getActivePositions(dept5) + + # Check that no positions are returned for a department with no positions + assert len(positionsList) == 0 + assert len(posURL) == 0 + + transaction.rollback() + +# what if there the department is None? +@pytest.mark.integration +def test_checkNoDepartment(): + """ + Checks the behavior of getActivePositions when the department is None. + It should return empty lists for both positionsList and posURL, indicating that no positions are available for a non-existent department. + """ +# checks when dept is None (that is similar to when Department.get raises DoesNotExist) + with mainDB.atomic() as transaction: + positionsList, posURL = getActivePositions(None) + + # Check that no positions are returned when department is None + assert len(positionsList) == 0 + assert len(posURL) == 0 + + transaction.rollback() + +@pytest.mark.integration +def test_checkPositionDuplicates(): + """ + Test to check if the function correctly handles duplicate position codes within the same department and duplicate elements across different departments. + """ + with mainDB.atomic() as transaction: + deptA = Department.create(departmentID=102, DEPT_NAME="Computer Science", ACCOUNT="6740", ORG="2114", departmentCompliance=True, isActive=True) + deptB = Department.create(departmentID=103, DEPT_NAME="Mathematics", ACCOUNT="6741", ORG="2115", departmentCompliance=True, isActive=True) + deptC = Department.create(departmentID=104, DEPT_NAME="Physics", ACCOUNT="6742", ORG="2116", departmentCompliance=True, isActive=True) + deptD = Department.create(departmentID=105, DEPT_NAME="Chemistry", ACCOUNT="6743", ORG="2117", departmentCompliance=True, isActive=True) + + position5 = PositionHistory.create(positionTitle="Teaching Assistant", + positionCode="S34522", + department=deptA, + status="Active", + wls=4, + revisionDate="2023-01-01", + description="") + + position5Dup = PositionHistory.create(positionTitle="Teaching Assistant", # Duplicate position from the same department, but with a different status (Inactive). + positionCode="S34522", + department=deptA, + status="Inactive", + wls=4, + revisionDate="2023-01-01", + description="") + + position9 = PositionHistory.create(positionTitle="Teaching Assistant", + positionCode="S34526", + department=deptA, + status="Active", + wls=4, + revisionDate="2023-01-01", + description="") + + # Note: position5DepartmentBCopy intentionally uses a different revisionDate + # (2026-01-01) than position5 (2023-01-01) to confirm revisionDate does NOT + # factor into which department keeps a contested positionCode. + position5DepartmentBCopy = PositionHistory.create(positionTitle="Book Handler", + positionCode="S34522", + department=deptB, + status="Active", + wls=4, + revisionDate="2026-01-01", + description="") + + position6 = PositionHistory.create(positionTitle="Research Assistant", + positionCode="S34523", + department=deptB, + status="Active", + wls=3, + revisionDate="2023-01-01", + description="") + + position7 = PositionHistory.create(positionTitle="Lab Assistant", + positionCode="S34524", + department=deptC, + status="Active", + wls=2, + revisionDate="2023-01-01", + description="") + + position8 = PositionHistory.create(positionTitle="Intern", + positionCode="S34525", + department=deptD, + status="Inactive", + wls=1, + revisionDate="2023-01-01", + description="") + + # Get the active positions for each department + positionsListA, posURLA = getActivePositions(deptA) + positionsListB, posURLB = getActivePositions(deptB) + positionsListC, posURLC = getActivePositions(deptC) + positionsListD, posURLD = getActivePositions(deptD) + + # Check that the correct number of active positions are returned for each department + assert len(positionsListA) == 2 # Has a duplicate position name, should return 2 unique active positions due to unique position codes. + assert len(posURLA) == 2 + + # Check if two different departments with the same position code are counted as separate active positions for each department. + # The position with code "S34522" is active in department A, so it should not be counted as active in department B. + assert len(positionsListB) == 1 + assert len(posURLB) == 1 + + assert len(positionsListC) == 1 + assert len(posURLC) == 1 + + assert len(positionsListD) == 0 + assert len(posURLD) == 0 + + # Check that the correct position titles and codes are returned for department B and A (Focused on the contested position code "S34522" due to the duplicate across departments) + assert "Research Assistant: (WLS 3)" in positionsListB + assert "Book Handler: (WLS 4)" not in positionsListB + assert "Book Handler: (WLS 4)" not in positionsListA + assert "S34523" in posURLB + assert "S34522" not in posURLB + transaction.rollback() + +@pytest.mark.integration +def test_checkPositionClaimOrderIndependentOfDeptOrder(): + """ + Checks that the order of department creation does not affect which department receives a contested position code. + The department that created the position first (based on the auto-incrementing ID) should be the one that retains the active position, regardless of the order in which departments were created. + """ + with mainDB.atomic() as transaction: + deptA = Department.create(departmentID=106, + DEPT_NAME="Biology", + ACCOUNT="6744", + ORG="2118", + departmentCompliance=True, + isActive=True) + + deptB = Department.create(departmentID=107, + DEPT_NAME="History", + ACCOUNT="6745", + ORG="2119", + departmentCompliance=True, + isActive=True) + + # deptB's row is created FIRST + positionB = PositionHistory.create(positionTitle="Archivist", + positionCode="S99001", + department=deptB, + status="Active", wls=2, + revisionDate="2023-01-01", + description="") + + positionA = PositionHistory.create(positionTitle="Curator", + positionCode="S99001", + department=deptA, + status="Active", + wls=2, + revisionDate="2023-01-01", + description="") + + positionsListA, posURLA = getActivePositions(deptA) + positionsListB, posURLB = getActivePositions(deptB) + + # deptB claimed the code first therefore deptA should lose it, not deptB + assert len(positionsListA) == 0 + assert len(positionsListB) == 1 + assert "Archivist: (WLS 2)" in positionsListB + + transaction.rollback() + +@pytest.mark.integration +def test_checkDuplicateActiveWithinSameDepartment(): + """ + Checks that if the same department has two active positions with the same position code, only one of them is counted in the results. + This test ensures that the function correctly filters out duplicates based on position code within the same department + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=108, + DEPT_NAME="Art", + ACCOUNT="6746", + ORG="2120", + departmentCompliance=True, + isActive=True) + + # Create two active positions with the same code in the same department + pos1 = PositionHistory.create(positionTitle="Assistant A", + positionCode="S99002", + department=dept, + status="Active", + wls=1, + revisionDate="2023-01-01", + description="") + + pos2 = PositionHistory.create(positionTitle="Assistant B", + positionCode="S99002", + department=dept, + status="Active", + wls=1, + revisionDate="2023-06-01", + description="") + + positionsList, posURL = getActivePositions(dept) + + # Only one should be counted despite two Active rows with the same code + assert len(positionsList) == 1 + assert len(posURL) == 1 + + transaction.rollback() + +@pytest.mark.integration +def test_checkThreeWayPositionCodeConflict(): + """ + Checks that if three different departments have active positions with the same position code, + only the department that created the position first (based on the auto-incrementing ID) retains the active position. + """ + with mainDB.atomic() as transaction: + deptA = Department.create(departmentID=109, + DEPT_NAME="Physics2", + ACCOUNT="6747", + ORG="2121", + departmentCompliance=True, + isActive=True) + + deptB = Department.create(departmentID=110, + DEPT_NAME="Chem2", + ACCOUNT="6748", + ORG="2122", + departmentCompliance=True, + isActive=True) + + deptC = Department.create(departmentID=111, + DEPT_NAME="Bio2", + ACCOUNT="6749", + ORG="2123", + departmentCompliance=True, + isActive=True) + + # Create three active positions with the same code in different departments + posA = PositionHistory.create(positionTitle="First Claim", + positionCode="S99003", + department=deptA, + status="Active", + wls=1, + revisionDate="2023-01-01", + description="") + + posB = PositionHistory.create(positionTitle="Second Claim", + positionCode="S99003", + department=deptB, + status="Active", + wls=1, + revisionDate="2023-01-01", + description="") + + posC = PositionHistory.create(positionTitle="Third Claim", + positionCode="S99003", + department=deptC, + status="Active", + wls=1, + revisionDate="2023-01-01", + description="") + + listA, urlA = getActivePositions(deptA) + listB, urlB = getActivePositions(deptB) + listC, urlC = getActivePositions(deptC) + + assert len(listA) == 1 # only the first-created claim survives + assert len(listB) == 0 + assert len(listC) == 0 + + transaction.rollback() + +@pytest.mark.integration +def test_checkInactiveElsewhereDoesNotBlockActiveClaim(): + """ + Checks that if a department has an inactive position with a certain position code, it does not block another department from claiming that position code as active. + """ + with mainDB.atomic() as transaction: + deptA = Department.create(departmentID=114, DEPT_NAME="Geology", ACCOUNT="6752", ORG="2126", departmentCompliance=True, isActive=True) + deptB = Department.create(departmentID=115, DEPT_NAME="Astronomy", ACCOUNT="6753", ORG="2127", departmentCompliance=True, isActive=True) + + # Create an inactive position in deptA and an active position in deptB with the same code + PositionHistory.create(positionTitle="Old Role", + positionCode="S99005", + department=deptA, + status="Inactive", + wls=1, + revisionDate="2020-01-01", + description="") + + PositionHistory.create(positionTitle="Current Role", + positionCode="S99005", + department=deptB, + status="Active", + wls=1, + revisionDate="2023-01-01", + description="") + + listB, urlB = getActivePositions(deptB) + + # deptA's row is Inactive, so it should never block deptB's Active claim + assert len(listB) == 1 + assert "Current Role: (WLS 1)" in listB + + transaction.rollback() \ No newline at end of file