├── .env.example ├── .gitignore ├── .python-version ├── README.md ├── Response.md ├── authentication ├── __init__.py ├── admin.py ├── apps.py ├── authentication_uml.png ├── email.py ├── models.py ├── serializers.py ├── signals.py ├── social_authentication.py ├── test_settings.py ├── tests.py ├── urls.py └── views.py ├── common ├── filterset.py └── helper.py ├── job_listing_api ├── __init__.py ├── admin.py ├── apps.py ├── email.py ├── models.py ├── permissions.py ├── serializers.py ├── signals.py ├── tests.py ├── urls.py └── views.py ├── knowledge_base_api ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── permissions.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── manage.py ├── pynigeriaBackend ├── __init__.py ├── asgi.py ├── exception_handler.py ├── pipeline.py ├── settings.py ├── urls.py └── wsgi.py ├── pyproject.toml ├── requirements.txt ├── schema.yml ├── templates └── email.html ├── todo.txt └── tracking ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── tests.py └── views.py /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=your-secret-key 2 | DEBUG=False 3 | ALLOWED_HOSTS=12.34.56,http://127.0.0.1 4 | CSRF_TRUSTED_ORIGINS=xxxxxxxxx 5 | 6 | DATABASE_URL=your-database-url 7 | 8 | # Email settings 9 | CURRENT_ORIGIN=xxxxxxxxx 10 | SENDER_EMAIL=xxxxxxxxx 11 | EMAIL_BACKEND=xxxxxxxxx 12 | EMAIL_HOST=xxxxxxxxx 13 | EMAIL_PORT=xxxxxxxxx 14 | EMAIL_USE_TLS=xxxxxxxxx 15 | EMAIL_HOST_USER=xxxxxxxxx 16 | EMAIL_HOST_PASSWORD=xxxxxxxxx 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### Django ### 3 | *.log 4 | *.pot 5 | *.pyc 6 | __pycache__/ 7 | local_settings.py 8 | db.sqlite3 9 | db.sqlite3-journal 10 | media 11 | 12 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 13 | # in your Git repository. Update and uncomment the following line accordingly. 14 | # /staticfiles/ 15 | 16 | ### Django.Python Stack ### 17 | # Byte-compiled / optimized / DLL files 18 | *.py[cod] 19 | *$py.class 20 | 21 | # C extensions 22 | *.so 23 | 24 | # Distribution / packaging 25 | .Python 26 | build/ 27 | develop-eggs/ 28 | dist/ 29 | downloads/ 30 | eggs/ 31 | .eggs/ 32 | lib/ 33 | lib64/ 34 | parts/ 35 | sdist/ 36 | var/ 37 | wheels/ 38 | share/python-wheels/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | MANIFEST 43 | 44 | # PyInstaller 45 | # Usually these files are written by a python script from a template 46 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 47 | *.manifest 48 | *.spec 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # Unit test / coverage reports 55 | htmlcov/ 56 | .tox/ 57 | .nox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | *.py,cover 65 | .hypothesis/ 66 | .pytest_cache/ 67 | cover/ 68 | 69 | # Translations 70 | *.mo 71 | 72 | # Django stuff: 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | .pybuilder/ 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # poetry 108 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 109 | # This is especially recommended for binary packages to ensure reproducibility, and is more 110 | # commonly ignored for libraries. 111 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 112 | #poetry.lock 113 | 114 | # pdm 115 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 116 | #pdm.lock 117 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 118 | # in version control. 119 | # https://pdm.fming.dev/#use-with-ide 120 | .pdm.toml 121 | 122 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 123 | __pypackages__/ 124 | 125 | # Celery stuff 126 | celerybeat-schedule 127 | celerybeat.pid 128 | 129 | # SageMath parsed files 130 | *.sage.py 131 | 132 | # Environments 133 | .env 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | 172 | ### Linux ### 173 | *~ 174 | 175 | # temporary files which can be created if a process still has a handle open of a deleted file 176 | .fuse_hidden* 177 | 178 | # KDE directory preferences 179 | .directory 180 | 181 | # Linux trash folder which might appear on any partition or disk 182 | .Trash-* 183 | 184 | # .nfs files are created when an open file is removed but is still being accessed 185 | .nfs* 186 | 187 | ### macOS ### 188 | # General 189 | .DS_Store 190 | .AppleDouble 191 | .LSOverride 192 | 193 | # Icon must end with two \r 194 | Icon 195 | 196 | 197 | # Thumbnails 198 | ._* 199 | 200 | # Files that might appear in the root of a volume 201 | .DocumentRevisions-V100 202 | .fseventsd 203 | .Spotlight-V100 204 | .TemporaryItems 205 | .Trashes 206 | .VolumeIcon.icns 207 | .com.apple.timemachine.donotpresent 208 | 209 | # Directories potentially created on remote AFP share 210 | .AppleDB 211 | .AppleDesktop 212 | Network Trash Folder 213 | Temporary Items 214 | .apdisk 215 | 216 | ### macOS Patch ### 217 | # iCloud generated files 218 | *.icloud 219 | 220 | ### Python ### 221 | # Byte-compiled / optimized / DLL files 222 | 223 | # C extensions 224 | 225 | # Distribution / packaging 226 | 227 | # PyInstaller 228 | # Usually these files are written by a python script from a template 229 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 230 | 231 | # Installer logs 232 | 233 | # Unit test / coverage reports 234 | 235 | # Translations 236 | 237 | # Django stuff: 238 | 239 | # Flask stuff: 240 | 241 | # Scrapy stuff: 242 | 243 | # Sphinx documentation 244 | 245 | # PyBuilder 246 | 247 | # Jupyter Notebook 248 | 249 | # IPython 250 | 251 | # pyenv 252 | # For a library or package, you might want to ignore these files since the code is 253 | # intended to run in multiple environments; otherwise, check them in: 254 | # .python-version 255 | 256 | # pipenv 257 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 258 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 259 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 260 | # install all needed dependencies. 261 | 262 | # poetry 263 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 264 | # This is especially recommended for binary packages to ensure reproducibility, and is more 265 | # commonly ignored for libraries. 266 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 267 | 268 | # pdm 269 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 270 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 271 | # in version control. 272 | # https://pdm.fming.dev/#use-with-ide 273 | 274 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 275 | 276 | # Celery stuff 277 | 278 | # SageMath parsed files 279 | 280 | # Environments 281 | 282 | # Spyder project settings 283 | 284 | # Rope project settings 285 | 286 | # mkdocs documentation 287 | 288 | # mypy 289 | 290 | # Pyre type checker 291 | 292 | # pytype static type analyzer 293 | 294 | # Cython debug symbols 295 | 296 | # PyCharm 297 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 298 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 299 | # and can be added to the global gitignore or merged into this file. For a more nuclear 300 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 301 | 302 | ### Python Patch ### 303 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 304 | poetry.toml 305 | 306 | # ruff 307 | .ruff_cache/ 308 | 309 | # LSP config files 310 | pyrightconfig.json 311 | 312 | ### Windows ### 313 | # Windows thumbnail cache files 314 | Thumbs.db 315 | Thumbs.db:encryptable 316 | ehthumbs.db 317 | ehthumbs_vista.db 318 | 319 | # Dump file 320 | *.stackdump 321 | 322 | # Folder config file 323 | [Dd]esktop.ini 324 | 325 | # Recycle Bin used on file shares 326 | $RECYCLE.BIN/ 327 | 328 | # Windows Installer files 329 | *.cab 330 | *.msi 331 | *.msix 332 | *.msm 333 | *.msp 334 | 335 | # Windows shortcuts 336 | *.lnk 337 | 338 | 339 | venv/ 340 | .env 341 | __pycache__/ 342 | *.pyc 343 | migrations/ 344 | db.sqlite3 345 | staticfiles/ 346 | a.md 347 | a.py 348 | a.js 349 | *.jpg 350 | response.json 351 | test.py 352 | aa.py 353 | dump.html 354 | request_test.py -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12.7 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyNigeria Backend 2 | ### This is the backend of the official website of Python Nigeria 3 | 4 | 5 | 6 | ### Current Features 7 | - Job listing 8 | - Job application 9 | --- 10 | 11 | ### Prerequisites 12 | 13 | Before starting, ensure you have the following installed: 14 | - Python (>= 3.10) 15 | - pip 16 | - Virtualenv/venv (recommended) 17 | - PostgreSQL/MySQL (or any database of choice) 18 | 19 | --- 20 | 21 | ### Installation 22 | 23 | Follow these steps to set up the project locally: 24 | 25 | 1. **Clone the repository:** 26 | ```bash 27 | git clone https://github.com/Python-Nigeria/pynigeria-backend.git 28 | cd pynigeria-backend 29 |
30 | 31 | 2. **Create and activate a virtual environment:** 32 | 33 | 34 |
35 | Windows 36 | 37 | 38 | python -m venv venv 39 | venv\\Scripts\\activate 40 | 41 |
42 | 43 | 44 |
45 | Linux/Mac 46 | 47 | 48 | python -m venv venv 49 | source venv/bin/activate 50 |
51 |
52 | 53 | 3. **Install dependencies:** 54 | ```bash 55 | pip install -r requirements.txt 56 |
57 | 58 | 4. **Create a `.env` file and set environment variables:** 59 | ```plaintext 60 | SECRET_KEY=your-secret-key 61 | DEBUG=False 62 | ALLOWED_HOSTS=12.34.56,http://127.0.0.1 63 | CSRF_TRUSTED_ORIGINS=xxxxxxxxx 64 | 65 | DATABASE_URL=your-database-url 66 | 67 | CURRENT_ORIGIN=xxxxxxxxx 68 | SENDER_EMAIL=xxxxxxxxx 69 | EMAIL_BACKEND=xxxxxxxxx 70 | EMAIL_HOST=xxxxxxxxx 71 | EMAIL_PORT=xxxxxxxxx 72 | EMAIL_USE_TLS=xxxxxxxxx 73 | EMAIL_HOST_USER=xxxxxxxxx 74 | EMAIL_HOST_PASSWORD=xxxxxxxxx 75 | 76 | ``` 77 |
78 | 79 | 5. **Apply migrations:** 80 | ```bash 81 | python manage.py migrate 82 |
83 | 84 | 6. **Run the server:** 85 | ```bash 86 | python manage.py runserver 87 | 88 | The server will be available at `http://127.0.0.1:8000/`. 89 | 90 | __________________________________________________ 91 | 92 |
93 | 94 | __________________________________________________ 95 | 96 | ### Testing 97 | 98 | Run tests using the following command: 99 | ```bash 100 | python manage.py test 101 | ``` 102 | 103 | __________________________________________________ 104 | 105 | ### Contributing 106 | 107 | Contributions are welcome! Follow these steps to contribute: 108 | 1. Fork the repository. 109 | 2. Create a new branch (`git checkout -b feature-branch`). 110 | 3. Commit changes (`git commit -m "Add feature"`). 111 | 4. Push to the branch (`git push origin feature-branch`). 112 | 5. Open a Pull Request. 113 | 114 | __________________________________________________ 115 | 116 | ### License 117 | 118 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 119 | 120 | __________________________________________________ 121 | 122 | Contact 123 | 124 | • Author Name: [Your Name] 125 | • Email: [your.email@example.com] 126 | • GitHub: [https://github.com/username](https://github.com/username) 127 | -------------------------------------------------------------------------------- /Response.md: -------------------------------------------------------------------------------- 1 | # Below are the Requsest body format and response to expect when a request is successful 2 | 3 | - ## When creating a job posting - Post Request 4 | 5 | ```json 6 | # This is how your request body will be like 7 | 8 | { 9 | "title": "Software Developer", 10 | "company": "Tech Solutions Inc.", 11 | "description": "We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 12 | "location": "New York, USA", 13 | "employment_type": "Full Time", 14 | "skills": [ 15 | {"name": "Python"}, 16 | {"name": "JavaScript"}, 17 | {"name": "Django"}, 18 | {"name": "React"}, 19 | {"name": "SQL"} 20 | ], 21 | "salary": 200000, 22 | "application_deadline": "2024-12-31" 23 | } 24 | 25 | And a successful response will return 26 | 27 | { 28 | "job": "http://localhost:8003/job/d283a127-b33c-4377-b90c-0cfcdc3fa945/", 29 | "skills": [ 30 | { 31 | "name": "Django" 32 | }, 33 | { 34 | "name": "Javascript" 35 | }, 36 | { 37 | "name": "Python" 38 | }, 39 | { 40 | "name": "React" 41 | }, 42 | { 43 | "name": "Sql" 44 | } 45 | ], 46 | "employment_type": "Full Time", 47 | "title": "Software Developer", 48 | "company": "Tech Solutions Inc.", 49 | "location": "New York, Usa", 50 | "description": "We are looking for a skilled software developer to join our team. the ideal candidate will have experience with python, javascript, and web development frameworks.", 51 | "application_deadline": "2024-12-31", 52 | "salary": "200000.00", 53 | "created_at": "2024-12-18", 54 | "posted_by": "Admin@Admin.Com" 55 | } 56 | ``` 57 | 58 | - ## When listing out all the job posting or trying to retrieve a singular instance - Get request 59 | 60 | This is a list of all instance 61 | 62 | ```json 63 | [ 64 | { 65 | "job": "http://localhost:8003/job/3477bd05-2ab1-4468-a31b-0838bb3e96d4/", 66 | "skills": [ 67 | { 68 | "name": "Django" 69 | }, 70 | { 71 | "name": "Javascript" 72 | }, 73 | { 74 | "name": "Python" 75 | }, 76 | { 77 | "name": "React" 78 | }, 79 | { 80 | "name": "Sql" 81 | } 82 | ], 83 | "employment_type": "Full Time", 84 | "title": "Software Developer", 85 | "company": "Tech Solutions Inc.", 86 | "location": "New York, Usa", 87 | "description": "We are looking for a skilled software developer to join our team. the ideal candidate will have experience with python, javascript, and web development frameworks.", 88 | "application_deadline": "2024-12-31", 89 | "salary": "20.00", 90 | "created_at": "2024-12-18", 91 | "posted_by": "Admin@Admin.Com" 92 | } 93 | ] 94 | ``` 95 | 96 | This is a singular instance 97 | 98 | ```json 99 | { 100 | "job": "http://localhost:8003/job/3477bd05-2ab1-4468-a31b-0838bb3e96d4/", 101 | "skills": [ 102 | { 103 | "name": "Django" 104 | }, 105 | { 106 | "name": "Javascript" 107 | }, 108 | { 109 | "name": "Python" 110 | }, 111 | { 112 | "name": "React" 113 | }, 114 | { 115 | "name": "Sql" 116 | } 117 | ], 118 | "employment_type": "Full Time", 119 | "title": "Software Developer", 120 | "company": "Tech Solutions Inc.", 121 | "location": "New York, Usa", 122 | "description": "We are looking for a skilled software developer to join our team. the ideal candidate will have experience with python, javascript, and web development frameworks.", 123 | "application_deadline": "2024-12-31", 124 | "salary": "20.00", 125 | "created_at": "2024-12-18", 126 | "posted_by": "Admin@Admin.Com" 127 | } 128 | ``` 129 | 130 | > **N.B:** The difference between the list of instanceand singular instance is that the list returns a list as in python `list` datatype and a singular instance returns a `dict` datatype. 131 | 132 | The request body for update request is the same as post request. 133 | -------------------------------------------------------------------------------- /authentication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/authentication/__init__.py -------------------------------------------------------------------------------- /authentication/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin, register 2 | 3 | from .models import OTPCode, User 4 | 5 | 6 | @register(User) 7 | class UserAdmin(ModelAdmin): 8 | list_display = [ 9 | "id", 10 | "email", 11 | "is_email_verified", 12 | "is_2fa_enabled", 13 | "is_superuser", 14 | "is_staff", 15 | "is_otp_email_sent", 16 | "created", 17 | "updated", 18 | "last_login", 19 | ] 20 | 21 | readonly_fields = ["password"] 22 | 23 | list_filter = [ 24 | "id", 25 | "email", 26 | "is_email_verified", 27 | "is_2fa_enabled", 28 | "is_superuser", 29 | "is_staff", 30 | "created", 31 | ] 32 | 33 | 34 | @register(OTPCode) 35 | class OTPCodeAdmin(ModelAdmin): 36 | list_display = ["code", "user", "expiry"] 37 | list_filter = ["user", "expiry"] 38 | -------------------------------------------------------------------------------- /authentication/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthenticationConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "authentication" 7 | 8 | def ready(self): 9 | from . import signals 10 | -------------------------------------------------------------------------------- /authentication/authentication_uml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/authentication/authentication_uml.png -------------------------------------------------------------------------------- /authentication/email.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import signing 3 | from django.core.mail import send_mail 4 | from pyotp import TOTP, random_base32 5 | 6 | from .models import OTPCode 7 | 8 | 9 | class EmailOTP: 10 | """ 11 | This handles generation of OTP codes for email verification and sending of verification links to new users. 12 | The 'send_email' method is called through a signal when a new user object is saved. 13 | """ 14 | 15 | def __init__(self, user): 16 | self.user = user 17 | self.code = None 18 | self.user_id = user.id 19 | self.user_email = user.email 20 | self.generate_otp() 21 | 22 | def generate_otp(self): 23 | self.code = TOTP(random_base32(), digits=6).now() 24 | 25 | def send_email(self): 26 | signed_token = signing.dumps( 27 | obj=(self.code, self.user_id), key=settings.SECRET_KEY 28 | ) 29 | verification_url = f"{settings.CURRENT_ORIGIN}/api/v1/authentication/verify-email/complete/{signed_token}/" 30 | html_message = f""" 31 | 32 | 33 |

34 | Click this link to verify your email:
35 | verification link 36 |

37 | 38 | 39 | """ 40 | subject = "Email Verification" 41 | try: 42 | mail_status = send_mail( 43 | subject=subject, 44 | message=html_message, 45 | from_email=settings.SENDER_EMAIL, 46 | recipient_list=[self.user_email], 47 | html_message=html_message, 48 | fail_silently=False, 49 | ) 50 | if mail_status == 1: 51 | OTPCode.objects.create(code=self.code, user=self.user) 52 | self.user.is_otp_email_sent = True 53 | self.user.save() 54 | except Exception as e: 55 | raise Exception(str(e)) 56 | -------------------------------------------------------------------------------- /authentication/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import ( 2 | AbstractBaseUser, 3 | BaseUserManager, 4 | PermissionsMixin, 5 | ) 6 | from django.db.models import ( 7 | CASCADE, 8 | BooleanField, 9 | CharField, 10 | DateTimeField, 11 | EmailField, 12 | Model, 13 | OneToOneField, 14 | ) 15 | from django.utils import timezone 16 | from nanoid import generate 17 | 18 | 19 | def generate_user_id(): 20 | return generate() 21 | 22 | 23 | class UserManager(BaseUserManager): 24 | """ 25 | Regular user accounts are set up passwordless, only superusers require a password. 26 | """ 27 | 28 | def _create_user(self, **kwargs): 29 | email = kwargs.pop("email") 30 | password = kwargs.pop("password", None) 31 | normalized_email = self.normalize_email(email) 32 | user = self.model(email=normalized_email, **kwargs) 33 | if password: 34 | user.set_password(password) # For superusers currently 35 | user.save(using=self._db) 36 | return user 37 | 38 | def create_superuser(self, **kwargs): 39 | kwargs.setdefault("is_email_verified", True) 40 | kwargs.setdefault("is_superuser", True) 41 | kwargs.setdefault("is_staff", True) 42 | return self._create_user(**kwargs) 43 | 44 | def create_user(self, **kwargs): 45 | return self._create_user(**kwargs) 46 | 47 | 48 | class User(AbstractBaseUser, PermissionsMixin): 49 | id = CharField( 50 | max_length=21, 51 | primary_key=True, 52 | editable=False, 53 | unique=True, 54 | default=generate_user_id, 55 | ) 56 | email = EmailField(max_length=120, blank=False, unique=True, db_index=True) 57 | is_email_verified = BooleanField(default=False, db_index=True) 58 | is_2fa_enabled = BooleanField(default=False, db_index=True) 59 | is_superuser = BooleanField(default=False) 60 | is_staff = BooleanField(default=False) 61 | is_otp_email_sent = BooleanField(default=False) 62 | is_test_user = BooleanField(default=False) # Useful only for running tests 63 | created = DateTimeField(auto_now_add=True) 64 | updated = DateTimeField(auto_now=True) 65 | last_login = DateTimeField(auto_now=True) 66 | 67 | objects = UserManager() 68 | 69 | USERNAME_FIELD = "email" 70 | 71 | class Meta: 72 | db_table = "user" 73 | ordering = ["-created"] 74 | 75 | def __str__(self): 76 | return self.email 77 | 78 | 79 | class OTPCode(Model): 80 | code = CharField(max_length=6, unique=True, db_index=True) 81 | user = OneToOneField(User, related_name="otp", on_delete=CASCADE) 82 | expiry = DateTimeField( 83 | default=timezone.now() + timezone.timedelta(minutes=15), 84 | editable=False, 85 | db_index=True, 86 | ) 87 | 88 | class Meta: 89 | db_table = "user_otp_code" 90 | ordering = ["-expiry"] 91 | 92 | def __str__(self): 93 | return f"{self.user.email}'s OTP code" 94 | -------------------------------------------------------------------------------- /authentication/serializers.py: -------------------------------------------------------------------------------- 1 | from base64 import b32encode 2 | 3 | from django.conf import settings 4 | from django.core import signing 5 | from django.db import IntegrityError, transaction 6 | from django.utils import timezone 7 | from django.utils.dateformat import format 8 | from django_otp.plugins.otp_totp.models import TOTPDevice 9 | from pyotp import TOTP 10 | from rest_framework.exceptions import AuthenticationFailed 11 | from rest_framework.serializers import ( 12 | BooleanField, 13 | CharField, 14 | EmailField, 15 | ModelSerializer, 16 | Serializer, 17 | SerializerMethodField, 18 | ValidationError, 19 | ) 20 | from rest_framework_simplejwt.tokens import RefreshToken 21 | 22 | from .email import EmailOTP 23 | from .models import OTPCode, User 24 | 25 | 26 | class UserSerializer(ModelSerializer): 27 | created = SerializerMethodField() 28 | 29 | class Meta: 30 | model = User 31 | fields = ("id", "email", "is_email_verified", "created") 32 | read_only_fields = ( 33 | "id", 34 | "email", 35 | "is_email_verified", 36 | "is_2fa_enabled", 37 | "created", 38 | ) 39 | 40 | def get_created(self, obj): 41 | return format(obj.created, "M d, Y. P") 42 | 43 | 44 | class TOTPDeviceSerializer(ModelSerializer): 45 | class Meta: 46 | model = TOTPDevice 47 | fields = ["user", "name", "confirmed"] 48 | 49 | 50 | class RegisterSerializer(Serializer): 51 | id = CharField(read_only=True) 52 | email = EmailField() 53 | is_email_verified = BooleanField(read_only=True) 54 | message = CharField(read_only=True) 55 | 56 | def validate(self, data): 57 | email = data.get("email", None) 58 | if User.objects.filter(email=email).exists(): 59 | raise IntegrityError("An account with this email already exists.") 60 | return data 61 | 62 | def save(self, **kwargs): 63 | return User.objects.create_user(**self.validated_data) 64 | 65 | def to_representation(self, instance): 66 | user_data = UserSerializer(instance).data 67 | user_data["message"] = "Check your email for a verification link." 68 | return user_data 69 | 70 | 71 | class EmailVerifyBeginSerializer(Serializer): 72 | email = EmailField(write_only=True) 73 | message = CharField(read_only=True) 74 | 75 | def validate(self, data): 76 | email = data.get("email", None) 77 | self.user = User.objects.select_related("otp").filter(email=email).first() 78 | if not self.user: 79 | raise ValidationError( 80 | detail={"error": "No existing account is associated with this email."} 81 | ) 82 | elif self.user.is_email_verified: 83 | raise ValidationError( 84 | detail={"error": "This user account has already been verified."} 85 | ) 86 | elif self.user.is_otp_email_sent and timezone.now() < self.user.otp.expiry: 87 | raise ValidationError( 88 | detail={ 89 | "error": "Check your email for an already existing verification link." 90 | } 91 | ) 92 | return data 93 | 94 | def save(self, **kwargs): 95 | EmailOTP(self.user).send_email() 96 | return "Check your email for a verification link." 97 | 98 | def to_representation(self, instance): 99 | instance = {"message": instance} 100 | return instance 101 | 102 | 103 | class EmailVerifyCompleteSerializer(Serializer): 104 | id = CharField(read_only=True) 105 | email = EmailField(read_only=True) 106 | is_email_verified = BooleanField(read_only=True) 107 | message = CharField(read_only=True) 108 | 109 | def validate(self, data): 110 | token = self.context.get("token") 111 | try: 112 | otp_data = signing.loads(token, key=settings.SECRET_KEY) 113 | except signing.BadSignature: 114 | raise ValidationError(detail={"error": "Invalid OTP code detected."}) 115 | self.otp = ( 116 | OTPCode.objects.select_related("user") 117 | .filter(code=otp_data[0], user_id=otp_data[1]) 118 | .first() 119 | ) 120 | if not self.otp: 121 | raise ValidationError(detail={"error": "OTP code does not exist."}) 122 | self.user = self.otp.user 123 | if timezone.now() > self.otp.expiry: 124 | with transaction.atomic(): 125 | self.otp.delete() 126 | self.user.is_otp_email_sent = False 127 | self.user.save() 128 | EmailOTP(self.user).send_email() 129 | raise ValidationError( 130 | detail={ 131 | "error": "OTP code has expired. A new verification link has been sent to your email." 132 | } 133 | ) 134 | return data 135 | 136 | def save(self, **kwargs): 137 | with transaction.atomic(): 138 | self.user.is_email_verified = True 139 | self.user.save() 140 | self.otp.delete() 141 | return self.user 142 | 143 | def to_representation(self, instance): 144 | user_data = UserSerializer(instance).data 145 | user_data.pop("created") 146 | user_data["message"] = ( 147 | "Your email has been verified successfully. Proceed to 2FA setup." 148 | ) 149 | return user_data 150 | 151 | 152 | class TOTPDeviceCreateSerializer(Serializer): 153 | user = CharField(read_only=True) 154 | name = CharField(read_only=True) 155 | email = EmailField() 156 | confirmed = BooleanField(read_only=True, default=False) 157 | 158 | def validate(self, data): 159 | self.email = data.get("email") 160 | self.user = User.objects.filter(email=self.email).first() 161 | if not self.user: 162 | raise ValidationError( 163 | detail={"error": "No existing account is associated with this email."} 164 | ) 165 | if not self.user.is_email_verified: 166 | raise ValidationError( 167 | detail={ 168 | "error": "This account has not been verified. Check your email for a verification link or request a new one." 169 | } 170 | ) 171 | if TOTPDevice.objects.filter(user=self.user).exists(): 172 | raise ValidationError( 173 | detail={"error": "A TOTP device already exists for this account."} 174 | ) 175 | return data 176 | 177 | def save(self, **kwargs): 178 | return TOTPDevice.objects.create( 179 | user=self.user, name=self.email, confirmed=False 180 | ) 181 | 182 | def to_representation(self, instance): 183 | return TOTPDeviceSerializer(instance).data 184 | 185 | 186 | class QRCodeDataSerializer(Serializer): 187 | otpauth_url = CharField(read_only=True) 188 | email = EmailField() 189 | 190 | def validate(self, data): 191 | self.email = data.get("email") 192 | self.device = TOTPDevice.objects.filter( 193 | name=self.email, confirmed=False 194 | ).first() 195 | if not self.device: 196 | raise ValidationError( 197 | detail={ 198 | "error": "No unconfirmed TOTP device is associated with this email." 199 | } 200 | ) 201 | return data 202 | 203 | def save(self, **kwargs): 204 | return self.device.config_url 205 | 206 | 207 | class VerifyTOTPDeviceSerializer(Serializer): 208 | email = EmailField() 209 | otp_token = CharField(write_only=True) 210 | user = CharField(read_only=True) 211 | name = CharField(read_only=True) 212 | confirmed = BooleanField(read_only=True) 213 | message = CharField(read_only=True) 214 | 215 | def validate(self, data): 216 | self.email = data.get("email") 217 | self.otp_token = data.get("otp_token") 218 | self.device = ( 219 | TOTPDevice.objects.select_related("user") 220 | .filter(name=self.email, confirmed=False) 221 | .first() 222 | ) 223 | if not self.device: 224 | raise ValidationError( 225 | detail={ 226 | "error": "No unconfirmed TOTP device is associated with this email." 227 | } 228 | ) 229 | secret_key = b32encode(self.device.bin_key).decode() 230 | totp = TOTP(secret_key) 231 | if not totp.verify(self.otp_token): 232 | raise ValidationError(detail={"error": "Invalid TOTP token detected."}) 233 | return data 234 | 235 | def save(self, **kwargs): 236 | with transaction.atomic(): 237 | self.device.user.is_2fa_enabled = True 238 | self.device.user.save() 239 | self.device.confirmed = True 240 | self.device.save() 241 | return self.device 242 | 243 | def to_representation(self, instance): 244 | result = TOTPDeviceSerializer(instance).data 245 | result["message"] = ( 246 | "Your TOTP device has been verified successfully. Proceed to login." 247 | ) 248 | return result 249 | 250 | 251 | class LoginSerializer(Serializer): 252 | email = EmailField() 253 | otp_code = CharField(write_only=True) 254 | id = CharField(read_only=True) 255 | access = CharField(read_only=True) 256 | refresh = CharField(read_only=True) 257 | 258 | def validate(self, data): 259 | self.email = data.get("email") 260 | user = User.objects.filter(email=self.email).first() 261 | if not user: 262 | raise ValidationError( 263 | {"error": "No account is associated with this email."} 264 | ) 265 | if not user.is_2fa_enabled: 266 | raise AuthenticationFailed("2FA setup must be completed before login.") 267 | self.device = ( 268 | TOTPDevice.objects.select_related("user") 269 | .filter(user__email=self.email, confirmed=True) 270 | .first() 271 | ) 272 | if not self.device: 273 | raise ValidationError( 274 | detail={ 275 | "error": "No confirmed TOTP device is associated with this email." 276 | } 277 | ) 278 | self.otp_code = data.get("otp_code") 279 | secret_key = b32encode(self.device.bin_key).decode() 280 | totp = TOTP(secret_key) 281 | if not totp.verify(self.otp_code): 282 | raise ValidationError(detail={"error": "Invalid TOTP token detected."}) 283 | return data 284 | 285 | def save(self, **kwargs): 286 | refresh_token = RefreshToken.for_user(self.device.user) 287 | access_token = refresh_token.access_token 288 | validated_data = self.validated_data 289 | validated_data.clear() 290 | validated_data["id"] = self.device.user.id 291 | validated_data["email"] = self.device.user.email 292 | validated_data["access"] = str(access_token) 293 | validated_data["refresh"] = str(refresh_token) 294 | return validated_data 295 | 296 | def to_representation(self, instance): 297 | return instance 298 | -------------------------------------------------------------------------------- /authentication/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | 4 | from .email import EmailOTP 5 | from .models import User 6 | 7 | 8 | @receiver(post_save, sender=User) 9 | def send_otp_email(sender, instance, created, **kwargs): 10 | """ 11 | This handles sending verification emails to new users after saving. 12 | """ 13 | try: 14 | if all([created, not instance.is_superuser, not instance.is_test_user]): 15 | EmailOTP(instance).send_email() 16 | except Exception as e: 17 | raise Exception(str(e)) 18 | -------------------------------------------------------------------------------- /authentication/social_authentication.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.exceptions import AuthenticationFailed 3 | from rest_framework.response import Response 4 | from rest_framework_simplejwt.tokens import RefreshToken 5 | from social_core.utils import ( 6 | partial_pipeline_data, 7 | user_is_active, 8 | user_is_authenticated, 9 | ) 10 | 11 | from .serializers import UserSerializer 12 | 13 | 14 | def complete_social_authentication(request, backend): 15 | backend = request.backend 16 | user = request.user 17 | 18 | # Check if user is authenticated 19 | is_user_authenticated = user_is_authenticated(user) 20 | user = user if is_user_authenticated else None 21 | 22 | # Complete any partial authentication or perform a full one 23 | partial = partial_pipeline_data(backend, user) 24 | if partial: 25 | user = backend.continue_pipeline(partial) 26 | backend.clean_partial_pipeline(partial.token) 27 | else: 28 | user = backend.complete(user=user) 29 | 30 | user_model = backend.strategy.storage.user.user_model() 31 | if user and not isinstance(user, user_model): 32 | raise AuthenticationFailed("Provided 'user' is not a valid User object.") 33 | if user: 34 | if user_is_active(user): 35 | is_new = getattr(user, "is_new", False) 36 | if is_new: 37 | user_data = UserSerializer(user).data 38 | user_data["message"] = "Proceed to 2FA setup." 39 | return Response({"data": user_data}, status=status.HTTP_201_CREATED) 40 | else: 41 | if not user.is_2fa_enabled: 42 | raise AuthenticationFailed( 43 | "2FA setup must be completed before login." 44 | ) 45 | else: 46 | refresh_token = RefreshToken.for_user(user) 47 | access_token = refresh_token.access_token 48 | return Response( 49 | { 50 | "data": dict( 51 | id=user.id, 52 | email=user.email, 53 | access=str(access_token), 54 | refresh=str(refresh_token), 55 | ) 56 | }, 57 | status=status.HTTP_200_OK, 58 | ) 59 | else: 60 | raise AuthenticationFailed("This account is inactive.") 61 | -------------------------------------------------------------------------------- /authentication/test_settings.py: -------------------------------------------------------------------------------- 1 | from pynigeriaBackend.settings import * 2 | 3 | REST_FRAMEWORK = { 4 | "REST_FRAMEWORK_THROTTLE_CLASSES": [ 5 | "rest_framework.throttling.AnonRateThrottle", 6 | ], 7 | "DEFAULT_THROTTLE_RATES": { 8 | "anon": "5000/min", 9 | }, 10 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 11 | "EXCEPTION_HANDLER": "pynigeriaBackend.exception_handler.pynigeria_exception_handler", 12 | } 13 | -------------------------------------------------------------------------------- /authentication/tests.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from django.core import mail 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | from django_otp.plugins.otp_totp.models import TOTP, TOTPDevice 7 | from rest_framework.test import APITransactionTestCase 8 | 9 | from .models import OTPCode, User 10 | 11 | """ 12 | RUN COMMAND: 13 | python3 manage.py test --settings=authentication.test_settings authentication 14 | """ 15 | 16 | 17 | class RegisterTestCase(APITransactionTestCase): 18 | def setUp(self): 19 | self.register_path = reverse("authentication:register") 20 | User.objects.create_user(email="test@gmail.com", is_test_user=True) 21 | 22 | def test_register_success(self): 23 | response = self.client.post( 24 | self.register_path, data={"email": "test1@gmail.com"} 25 | ) 26 | for field in {"id", "email", "message"}: 27 | self.assertTrue(field in response.data["data"]) 28 | self.assertEqual(response.data["data"]["is_email_verified"], False) 29 | 30 | user = User.objects.filter(email="test1@gmail.com").first() 31 | self.assertTrue(user.is_otp_email_sent) 32 | # Test for sent email 33 | self.assertIsNotNone(mail.outbox[0].body.split("'")[1].split("/")[7]) 34 | 35 | def test_register_integrity_failure(self): 36 | response = self.client.post( 37 | self.register_path, data={"email": "test@gmail.com"} 38 | ) 39 | self.assertEqual( 40 | response.data["detail"], "An account with this email already exists." 41 | ) 42 | 43 | def tearDown(self): 44 | User.objects.all().delete() 45 | mail.outbox.clear() 46 | 47 | 48 | class VerifyEmailBeginTestCase(APITransactionTestCase): 49 | def setUp(self): 50 | self.verify_begin_path = reverse("authentication:verify-email-begin") 51 | self.user = User.objects.create_user(email="test@gmail.com", is_test_user=True) 52 | 53 | def test_verify_email_begin_success(self): 54 | response = self.client.post( 55 | self.verify_begin_path, data={"email": self.user.email} 56 | ) 57 | self.assertEqual( 58 | response.data["data"]["message"], 59 | "Check your email for a verification link.", 60 | ) 61 | self.assertIsNotNone(self.user.otp.code) 62 | self.assertIsNotNone(mail.outbox[0].body.split("'")[1].split("/")[7]) 63 | 64 | def test_verify_email_begin_missing_field_failure(self): 65 | response = self.client.post(self.verify_begin_path, data={}) 66 | self.assertEqual(response.data["detail"], "Email field is required.") 67 | 68 | def test_verify_email_begin_non_existing_user_failure(self): 69 | response = self.client.post( 70 | self.verify_begin_path, data={"email": "test2@gmail.com"} 71 | ) 72 | self.assertEqual( 73 | response.data["detail"], 74 | "No existing account is associated with this email.", 75 | ) 76 | 77 | def test_verify_email_begin_existing_otp_failure(self): 78 | self.user2 = User.objects.create_user( 79 | email="test2@gmail.com", is_test_user=True 80 | ) 81 | self.client.post(self.verify_begin_path, data={"email": self.user2.email}) 82 | response = self.client.post( 83 | self.verify_begin_path, data={"email": self.user2.email} 84 | ) 85 | self.assertEqual( 86 | response.data["detail"], 87 | "Check your email for an already existing verification link.", 88 | ) 89 | self.assertIsNotNone(mail.outbox[0].body.split("'")[1].split("/")[7]) 90 | 91 | def tearDown(self): 92 | User.objects.all().delete() 93 | OTPCode.objects.all().delete() 94 | mail.outbox.clear() 95 | 96 | 97 | class VerifyEmailCompleteTestCase(APITransactionTestCase): 98 | def setUp(self): 99 | self.user = User.objects.create_user(email="test@gmail.com") 100 | self.verification_token = mail.outbox[0].body.split("'")[1].split("/")[7] 101 | 102 | def test_verify_email_complete_success(self): 103 | response = self.client.post( 104 | reverse( 105 | "authentication:verify-email-complete", 106 | kwargs={"token": self.verification_token}, 107 | ), 108 | data={}, 109 | ) 110 | self.assertEqual( 111 | response.data["data"]["message"], 112 | "Your email has been verified successfully. Proceed to 2FA setup.", 113 | ) 114 | response2 = self.client.post( 115 | reverse( 116 | "authentication:verify-email-complete", 117 | kwargs={"token": self.verification_token}, 118 | ), 119 | data={}, 120 | ) 121 | self.assertEqual(response2.data["detail"], "Otp code does not exist.") 122 | 123 | def test_verify_email_complete_invalid_code_failure(self): 124 | response = self.client.post( 125 | reverse( 126 | "authentication:verify-email-complete", 127 | kwargs={"token": self.verification_token + "n"}, 128 | ), 129 | data={}, 130 | ) 131 | self.assertEqual(response.data["detail"], "Invalid otp code detected.") 132 | 133 | def test_verify_email_complete_expired_failure(self): 134 | user2 = User.objects.create_user(email="test2@gmail.com") 135 | otp = OTPCode.objects.filter(user=user2).first() 136 | otp.expiry = timezone.now() - timezone.timedelta(minutes=15) 137 | otp.save() 138 | verification_token2 = mail.outbox[1].body.split("'")[1].split("/")[7] 139 | response = self.client.post( 140 | reverse( 141 | "authentication:verify-email-complete", 142 | kwargs={"token": verification_token2}, 143 | ), 144 | data={}, 145 | ) 146 | self.assertEqual( 147 | response.data["detail"], 148 | "Otp code has expired. a new verification link has been sent to your email.", 149 | ) 150 | self.assertIsNotNone(mail.outbox[2].body.split("'")[1].split("/")[7]) 151 | 152 | def tearDown(self): 153 | User.objects.all().delete() 154 | OTPCode.objects.all().delete() 155 | mail.outbox.clear() 156 | 157 | 158 | class TOTPCreateVerifyTestCase(APITransactionTestCase): 159 | def setUp(self): 160 | self.device_create = reverse("authentication:create-totp-device") 161 | self.user = User.objects.create_user( 162 | email="admin@gmail.com", is_email_verified=True 163 | ) 164 | 165 | def test_create_device_success(self): 166 | response = self.client.post(self.device_create, data={"email": self.user.email}) 167 | for item in {"user", "name", "confirmed"}: 168 | self.assertIn(item, response.data["data"]) 169 | self.assertFalse(response.data["data"]["confirmed"]) 170 | 171 | response2 = self.client.post( 172 | self.device_create, data={"email": self.user.email} 173 | ) 174 | self.assertEqual( 175 | response2.data["detail"], "A totp device already exists for this account." 176 | ) 177 | 178 | def test_create_device_failure_unverified_email(self): 179 | self.user.is_email_verified = False 180 | self.user.save() 181 | response = self.client.post(self.device_create, data={"email": self.user.email}) 182 | self.assertEqual( 183 | response.data["detail"], 184 | "This account has not been verified. check your email for a verification link or request a new one.", 185 | ) 186 | 187 | def test_create_device_failure_nonexisting_user(self): 188 | response = self.client.post( 189 | self.device_create, data={"email": "test@gmail.none"} 190 | ) 191 | self.assertEqual( 192 | response.data["detail"], 193 | "No existing account is associated with this email.", 194 | ) 195 | 196 | def tearDown(self): 197 | User.objects.all().delete() 198 | TOTPDevice.objects.all().delete() 199 | 200 | 201 | class GetQRCodeTestCase(APITransactionTestCase): 202 | def setUp(self): 203 | self.qrcode = reverse("authentication:get-qr-code") 204 | self.user = User.objects.create_user( 205 | email="admin@gmail.com", is_email_verified=True 206 | ) 207 | 208 | def test_get_qrcode_success(self): 209 | self.client.post( 210 | reverse("authentication:create-totp-device"), 211 | data={"email": self.user.email}, 212 | ) 213 | response = self.client.post(self.qrcode, data={"email": self.user.email}) 214 | self.assertTrue(type(response.data) == bytes) 215 | 216 | def test_get_qrcode_failure(self): 217 | response = self.client.post(self.qrcode, data={"email": self.user.email}) 218 | self.assertEqual( 219 | response.data["detail"], 220 | "No unconfirmed totp device is associated with this email.", 221 | ) 222 | 223 | def tearDown(self): 224 | User.objects.all().delete() 225 | TOTPDevice.objects.all().delete() 226 | 227 | 228 | class VerifyTOTPDeviceTestCase(APITransactionTestCase): 229 | def setUp(self): 230 | self.verify_device = reverse("authentication:verify-totp-device") 231 | self.user = User.objects.create_user( 232 | email="admin@gmail.com", is_email_verified=True 233 | ) 234 | 235 | def test_verify_totp_device_success(self): 236 | self.client.post( 237 | reverse("authentication:create-totp-device"), 238 | data={"email": self.user.email}, 239 | ) 240 | device = TOTPDevice.objects.filter(user=self.user).first() 241 | otp_token = TOTP(device.bin_key).token() 242 | response = self.client.post( 243 | self.verify_device, 244 | data={"email": self.user.email, "otp_token": otp_token}, 245 | ) 246 | for item in {"user", "name", "confirmed", "message"}: 247 | self.assertIn(item, response.data["data"]) 248 | self.assertTrue(response.data["data"]["confirmed"]) 249 | 250 | def test_verify_totp_device_failure_invalid_code(self): 251 | self.client.post( 252 | reverse("authentication:create-totp-device"), 253 | data={"email": self.user.email}, 254 | ) 255 | response = self.client.post( 256 | self.verify_device, data={"email": self.user.email, "otp_token": 123456} 257 | ) 258 | self.assertEqual(response.data["detail"], "Invalid totp token detected.") 259 | 260 | def tearDown(self): 261 | User.objects.all().delete() 262 | TOTPDevice.objects.all().delete() 263 | 264 | 265 | class LoginTestCase(APITransactionTestCase): 266 | def setUp(self): 267 | self.user = User.objects.create_user( 268 | email="admin@gmail.com", is_email_verified=True 269 | ) 270 | self.otp_device = TOTPDevice.objects.create( 271 | user=self.user, name=self.user.email, confirmed=True 272 | ) 273 | self.login = reverse("authentication:login") 274 | 275 | def test_login_success(self): 276 | self.user.is_2fa_enabled = True 277 | self.user.save() 278 | otp_code = TOTP(self.otp_device.bin_key).token() 279 | response = self.client.post( 280 | self.login, data={"email": self.user.email, "otp_code": otp_code} 281 | ) 282 | self.assertIn("access", response.data["data"]) 283 | self.assertEqual(response.status_code, 200) 284 | 285 | def test_login_failure_no_account(self): 286 | self.user.is_2fa_enabled = True 287 | self.user.save() 288 | otp_code = TOTP(self.otp_device.bin_key).token() 289 | response = self.client.post( 290 | self.login, data={"email": "admi1n@gmail.com", "otp_code": otp_code} 291 | ) 292 | self.assertEqual( 293 | response.data["detail"], "No account is associated with this email." 294 | ) 295 | 296 | def test_login_failure_2fa_not_set(self): 297 | response = self.client.post( 298 | self.login, data={"email": self.user.email, "otp_code": 386267} 299 | ) 300 | self.assertEqual( 301 | response.data["detail"], "2FA setup must be completed before login." 302 | ) 303 | 304 | def test_login_failure_no_totp_device(self): 305 | self.user.is_2fa_enabled = True 306 | self.user.save() 307 | self.otp_device.delete() 308 | response = self.client.post( 309 | self.login, data={"email": self.user.email, "otp_code": 638387} 310 | ) 311 | self.assertEqual( 312 | response.data["detail"], 313 | "No confirmed totp device is associated with this email.", 314 | ) 315 | 316 | def tearDown(self): 317 | return super().tearDown() 318 | -------------------------------------------------------------------------------- /authentication/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from social_django.urls import extra 3 | 4 | from .views import ( 5 | GetQRCodeView, 6 | LoginView, 7 | RegisterView, 8 | SocialAuthenticationBeginView, 9 | SocialAuthenticationCompleteView, 10 | TOTPDeviceCreateView, 11 | VerifyEmailBeginView, 12 | VerifyEmailCompleteView, 13 | VerifyTOTPDeviceView, 14 | CsrfTokenView 15 | ) 16 | 17 | app_name = "authentication" 18 | 19 | urlpatterns = [ 20 | path("register/", RegisterView.as_view(), name="register"), 21 | path( 22 | "verify-email/begin/", VerifyEmailBeginView.as_view(), name="verify-email-begin" 23 | ), 24 | path( 25 | "verify-email/complete//", 26 | VerifyEmailCompleteView.as_view(), 27 | name="verify-email-complete", 28 | ), 29 | path( 30 | "totp-device/create/", TOTPDeviceCreateView.as_view(), name="create-totp-device" 31 | ), 32 | path("totp-device/qrcode/", GetQRCodeView.as_view(), name="get-qr-code"), 33 | path( 34 | "totp-device/verify/", VerifyTOTPDeviceView.as_view(), name="verify-totp-device" 35 | ), 36 | path("login/", LoginView.as_view(), name="login"), 37 | path(f"social/begin/{extra}", SocialAuthenticationBeginView.as_view(), name="social-begin"), 38 | path("social/complete//", SocialAuthenticationCompleteView.as_view(), name="social-complete"), 39 | path("csrfToken/",CsrfTokenView.as_view()) 40 | ] 41 | -------------------------------------------------------------------------------- /authentication/views.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import qrcode 4 | from django.contrib.auth import REDIRECT_FIELD_NAME 5 | from django.shortcuts import redirect 6 | from django.utils.decorators import method_decorator 7 | from django.views.decorators.cache import never_cache 8 | from django.views.decorators.csrf import csrf_exempt 9 | from drf_spectacular.utils import extend_schema 10 | from rest_framework import status 11 | from rest_framework.renderers import BaseRenderer, BrowsableAPIRenderer 12 | from rest_framework.response import Response 13 | from rest_framework.throttling import AnonRateThrottle 14 | from rest_framework.views import APIView 15 | from social_core.actions import do_auth 16 | from social_django.utils import psa 17 | 18 | from .serializers import ( 19 | EmailVerifyBeginSerializer, 20 | EmailVerifyCompleteSerializer, 21 | LoginSerializer, 22 | QRCodeDataSerializer, 23 | RegisterSerializer, 24 | TOTPDeviceCreateSerializer, 25 | VerifyTOTPDeviceSerializer, 26 | ) 27 | from .social_authentication import complete_social_authentication 28 | from django.middleware.csrf import get_token 29 | from rest_framework.permissions import AllowAny 30 | 31 | 32 | class RegisterView(APIView): 33 | serializer_class = RegisterSerializer 34 | throttle_classes = [AnonRateThrottle] 35 | permission_classes = [AllowAny] 36 | 37 | @extend_schema(operation_id="v1_register", tags=["auth_v1"]) 38 | def post(self, request): 39 | serializer = self.serializer_class(data=request.data) 40 | if serializer.is_valid(raise_exception=True): 41 | new_user_data = serializer.save() 42 | response_data = self.serializer_class(new_user_data).data 43 | return Response({"data": response_data}, status=status.HTTP_201_CREATED) 44 | 45 | 46 | class VerifyEmailBeginView(APIView): 47 | """ 48 | This view exists to initiate email verification manually if the auto option fails. 49 | """ 50 | 51 | serializer_class = EmailVerifyBeginSerializer 52 | throttle_classes = [AnonRateThrottle] 53 | permission_classes = [AllowAny] 54 | 55 | @extend_schema(operation_id="v1_verify_email_begin", tags=["auth_v1"]) 56 | def post(self, request): 57 | serializer = self.serializer_class(data=request.data) 58 | if serializer.is_valid(raise_exception=True): 59 | user_data = serializer.save() 60 | response_data = self.serializer_class(user_data).data 61 | return Response({"data": response_data}, status=status.HTTP_200_OK) 62 | 63 | 64 | class VerifyEmailCompleteView(APIView): 65 | serializer_class = EmailVerifyCompleteSerializer 66 | throttle_classes = [AnonRateThrottle] 67 | permission_classes = [AllowAny] 68 | 69 | @extend_schema(operation_id="v1_verify_email_complete", tags=["auth_v1"]) 70 | def post(self, request, token): 71 | serializer = self.serializer_class(data={}, context={"token": token}) 72 | if serializer.is_valid(raise_exception=True): 73 | user_data = serializer.save() 74 | response_data = self.serializer_class(user_data).data 75 | return Response({"data": response_data}, status=status.HTTP_200_OK) 76 | 77 | 78 | class TOTPDeviceCreateView(APIView): 79 | serializer_class = TOTPDeviceCreateSerializer 80 | throttle_classes = [AnonRateThrottle] 81 | permission_classes = [AllowAny] 82 | 83 | @extend_schema(operation_id="v1_create_totp_device", tags=["auth_v1"]) 84 | def post(self, request): 85 | serializer = self.serializer_class(data=request.data) 86 | if serializer.is_valid(raise_exception=True): 87 | device_data = serializer.save() 88 | response_data = self.serializer_class(device_data).data 89 | return Response({"data": response_data}, status=status.HTTP_201_CREATED) 90 | 91 | 92 | class GetQRCodeView(APIView): 93 | serializer_class = QRCodeDataSerializer 94 | throttle_classes = [AnonRateThrottle] 95 | permission_classes = [AllowAny] 96 | 97 | class PNGRenderer(BaseRenderer): 98 | media_type = "image/png" 99 | format = "png" 100 | charset = None 101 | render_style = "binary" 102 | 103 | def render(self, data, accepted_media_type=None, renderer_context=None): 104 | return data 105 | 106 | @extend_schema(operation_id="v1_get_qrcode", tags=["auth_v1"]) 107 | def post(self, request): 108 | serializer = self.serializer_class(data=request.data) 109 | if serializer.is_valid(raise_exception=True): 110 | otpauth_url = serializer.save() 111 | qr = qrcode.QRCode( 112 | version=1, 113 | error_correction=qrcode.ERROR_CORRECT_H, 114 | box_size=10, 115 | border=4, 116 | ) 117 | qr.add_data(otpauth_url) 118 | qr.make(fit=True) 119 | 120 | img = qr.make_image(fill_color=(0, 0, 0), back_color=(255, 255, 255)) 121 | image_buffer = BytesIO() 122 | img.save(image_buffer) 123 | image_buffer.seek(0) 124 | return Response( 125 | image_buffer.getvalue(), 126 | content_type="image/png", 127 | status=status.HTTP_200_OK, 128 | ) 129 | 130 | def finalize_response(self, request, response, *args, **kwargs): 131 | """ 132 | This method defines renderers for both image and text. 133 | PNGRenderer is used when the response contains the QR code. 134 | BrowsableAPIRenderer is in case of error messages, compatible with DRF's browsable API. 135 | """ 136 | if response.content_type == "image/png": 137 | response.accepted_renderer = GetQRCodeView.PNGRenderer() 138 | response.accepted_media_type = GetQRCodeView.PNGRenderer.media_type 139 | response.renderer_context = {} 140 | else: 141 | response.accepted_renderer = BrowsableAPIRenderer() 142 | response.accepted_media_type = BrowsableAPIRenderer.media_type 143 | response.renderer_context = { 144 | "response": response.data, 145 | "view": self, 146 | "request": request, 147 | } 148 | for key, value in self.headers.items(): 149 | response[key] = value 150 | return response 151 | 152 | 153 | class VerifyTOTPDeviceView(APIView): 154 | serializer_class = VerifyTOTPDeviceSerializer 155 | throttle_classes = [AnonRateThrottle] 156 | permission_classes = [AllowAny] 157 | 158 | @extend_schema(operation_id="v1_verify_totp_device", tags=["auth_v1"]) 159 | def post(self, request): 160 | serializer = self.serializer_class(data=request.data) 161 | if serializer.is_valid(raise_exception=True): 162 | device_data = serializer.save() 163 | response_data = self.serializer_class(device_data).data 164 | return Response({"data": response_data}, status=status.HTTP_200_OK) 165 | 166 | 167 | class LoginView(APIView): 168 | serializer_class = LoginSerializer 169 | throttle_classes = [AnonRateThrottle] 170 | permission_classes = [AllowAny] 171 | 172 | @extend_schema(operation_id="v1_login", tags=["auth_v1"]) 173 | def post(self, request): 174 | serializer = self.serializer_class(data=request.data) 175 | if serializer.is_valid(raise_exception=True): 176 | user_data = serializer.save() 177 | response_data = self.serializer_class(user_data).data 178 | return Response({"data": response_data}, status=status.HTTP_200_OK) 179 | 180 | 181 | @method_decorator( 182 | [csrf_exempt, never_cache, psa("authentication:social-complete")], name="get" 183 | ) 184 | class SocialAuthenticationBeginView(APIView): 185 | """This view initiates social oauth authentication""" 186 | 187 | throttle_classes = [AnonRateThrottle] 188 | permission_classes = [AllowAny] 189 | 190 | @extend_schema( 191 | operation_id="v1_social_auth_begin", 192 | tags=["auth_v1"], 193 | request=None, 194 | responses=None, 195 | ) 196 | def get(self, request, backend): 197 | return do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME) 198 | 199 | 200 | @method_decorator( 201 | [csrf_exempt, never_cache, psa("authentication:social-complete")], name="get" 202 | ) 203 | class SocialAuthenticationCompleteView(APIView): 204 | """This view completes social oauth authentication""" 205 | 206 | throttle_classes = [AnonRateThrottle] 207 | permission_classes = [AllowAny] 208 | 209 | @extend_schema( 210 | operation_id="v1_social_auth_complete", 211 | tags=["auth_v1"], 212 | request=None, 213 | responses=None, 214 | ) 215 | def get(self, request, backend): 216 | return complete_social_authentication(request, backend) 217 | 218 | class CsrfTokenView(APIView): 219 | permission_classes = [AllowAny] 220 | def get(self,request): 221 | #Genrate and get CSRF token 222 | csrf_token = get_token(request) 223 | return Response(dict(csrfToken=csrf_token)) -------------------------------------------------------------------------------- /common/filterset.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | 3 | from job_listing_api.models import Job, JobTypeChoice 4 | 5 | 6 | class JobFilterset(django_filters.FilterSet): 7 | job_title = django_filters.CharFilter( 8 | field_name="job_title", 9 | lookup_expr="icontains", 10 | label="Job Title" 11 | ) 12 | tags__name = django_filters.CharFilter( 13 | field_name="tags__name", 14 | lookup_expr="icontains", 15 | label="Tag", 16 | ) 17 | 18 | employment_type = django_filters.ChoiceFilter(choices=JobTypeChoice.choices, label="Job Type") 19 | 20 | class SalaryRangeFilter(django_filters.RangeFilter): 21 | def filter(self, qs, value): 22 | """ 23 | Override the RangeFilter to convert salary range from naira to kobo. 24 | """ 25 | if value: 26 | # Convert both bounds of the range from naira to kobo 27 | salary_min = value.start * 100 if value.start is not None else None 28 | salary_max = value.stop * 100 if value.stop is not None else None 29 | 30 | # Apply the range filter using the converted values 31 | if salary_min is not None and salary_max is not None: 32 | return qs.filter(**{f"{self.field_name}__gte": salary_min, f"{self.field_name}__lte": salary_max}) 33 | elif salary_min is not None: 34 | return qs.filter(**{f"{self.field_name}__gte": salary_min}) 35 | elif salary_max is not None: 36 | return qs.filter(**{f"{self.field_name}__lte": salary_max}) 37 | return qs 38 | salary = SalaryRangeFilter(field_name="salary") 39 | 40 | 41 | class Meta: 42 | model = Job 43 | fields = [ 44 | "job_title", 45 | "tags__name", 46 | "employment_type", 47 | "salary" 48 | ] 49 | 50 | -------------------------------------------------------------------------------- /common/helper.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import random 3 | import uuid 4 | from datetime import datetime 5 | from decimal import Decimal 6 | 7 | from django.contrib.auth import get_user_model 8 | from django.urls import reverse 9 | from job_listing_api.models import Job 10 | 11 | 12 | User = get_user_model() 13 | 14 | 15 | class Helper: 16 | 17 | def generate_slug(self): 18 | # Generate initial UUID 19 | initial_uuid = uuid.uuid4() 20 | 21 | # Convert UUID to bytes and hash it 22 | hash_obj = hashlib.sha256(str(initial_uuid).encode()) 23 | hash_bytes = hash_obj.digest() 24 | 25 | # Use the first 16 bytes of the hash to create a new UUID (version 3) 26 | new_uuid = uuid.UUID(bytes=hash_bytes[:16], version=4) 27 | 28 | return new_uuid 29 | 30 | def _format_list_fields(self, data): 31 | for field in ["job_skills"]: 32 | if field in data and data[field]: 33 | for items in data[field]: 34 | if "skill" in items and items["skill"] is not None: 35 | items["skill"] = {"name": items["skill"]["name"].title()} 36 | # data[field] = 37 | 38 | def _format_posted_by(self, data_field:str, data): 39 | if data_field in data: 40 | user = User.objects.filter(id=data[data_field]).first() 41 | data[data_field] = user.email.title() if user else None 42 | 43 | # def _format_job_instance(self, data_field, data, request): 44 | # if data_field in data and data[data_field]: 45 | # job = Job.objects.filter(id=data[data_field]).first() 46 | # if job: 47 | # relative_url = reverse('job-detail', kwargs={'slug': job.slug}) 48 | # data[data_field] = request.build_absolute_uri(relative_url) 49 | # else: 50 | # data[data_field] = None 51 | 52 | def _format_text_field(self, data): 53 | for field in ["job_title", "folder_name"]: 54 | if field in data and data[field]: 55 | data[field] = data[field].title() 56 | 57 | if "job_description" in data and data["job_description"]: 58 | data["job_description"] = data["job_description"].capitalize() 59 | 60 | if "folder_description" in data and data["folder_description"]: 61 | data["folder_description"] = data["folder_description"].capitalize() 62 | 63 | if "notes" in data and data["notes"]: 64 | data["notes"] = data["notes"].capitalize() 65 | 66 | def _format_date_field(self, data): 67 | for field in [ 68 | "created_at", 69 | "application_deadline", 70 | "scheduled_publish_at", 71 | "published_at", 72 | "updated_at" 73 | ]: 74 | if field in data and data[field]: 75 | try: 76 | data[field] = datetime.fromisoformat(data[field]).strftime( 77 | "%Y-%m-%d %H:%M:%S" 78 | ) 79 | except (ValueError, TypeError): 80 | # Fallback to original value if parsing fails 81 | pass 82 | 83 | def _format_salary(self, data): 84 | salary = Decimal(data.get("salary", 0)) 85 | if "salary" in data and data["salary"]: 86 | data["salary"] = str(salary / 100) 87 | -------------------------------------------------------------------------------- /job_listing_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/job_listing_api/__init__.py -------------------------------------------------------------------------------- /job_listing_api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin, register 2 | 3 | from .models import Bookmark, BookmarkFolder, Company, Job, JobSkill, Skill 4 | 5 | 6 | # Register your models here. 7 | @register(Job) 8 | class JobAdmin(ModelAdmin): 9 | list_display = ( 10 | "job_title", 11 | "company_name", 12 | "created_at", 13 | "slug" 14 | ) 15 | list_filter = ("job_title", "company_name", "employment_type", "salary") 16 | readonly_fields = ["created_at"] 17 | 18 | 19 | @register(Skill) 20 | class SkillAdmin(ModelAdmin): 21 | list_filter = ["name"] 22 | 23 | 24 | @register(Bookmark) 25 | class BookmarkAdmin(ModelAdmin): 26 | pass 27 | # list_filter = [ 28 | # "user__email", 29 | # "job__company__name", 30 | # "job__company__location", 31 | # "job__employment_type", 32 | # ] 33 | # list_display = [ 34 | # "user__email", 35 | # "job__company__name", 36 | # "job__company__location", 37 | # "job__employment_type", 38 | # ] 39 | 40 | 41 | @register(BookmarkFolder) 42 | class BookmarkFolderAdmin(ModelAdmin): 43 | pass 44 | 45 | 46 | @register(JobSkill) 47 | class JobSkillAdmin(ModelAdmin): 48 | pass 49 | 50 | 51 | # @register(JobTag) 52 | # class JobTagAdmin(ModelAdmin): 53 | # pass 54 | 55 | 56 | @register(Company) 57 | class CompanyAdmin(ModelAdmin): 58 | pass 59 | -------------------------------------------------------------------------------- /job_listing_api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class JobApiConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "job_listing_api" 7 | verbose_name = "Job Listing" 8 | 9 | def ready(self) -> None: 10 | from . import signals 11 | -------------------------------------------------------------------------------- /job_listing_api/email.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from django.core.mail import send_mail 6 | from django.template.loader import render_to_string 7 | from django.utils.html import strip_tags 8 | 9 | User = get_user_model() 10 | 11 | 12 | class JobNotificationEmail: 13 | def __init__(self, job_instance): 14 | """ 15 | Initialize with the job instance and recipient email. 16 | """ 17 | self.job_instance = job_instance 18 | 19 | def __send_to_email(self, subject, recipient_list, context): 20 | html_message = render_to_string("email.html", context) 21 | with open("templates/dump.html", "w") as file: 22 | file.write(html_message) 23 | plain_message = strip_tags(html_message) 24 | 25 | # Send the email 26 | send_mail( 27 | subject=subject, 28 | message=plain_message, 29 | from_email=settings.DEFAULT_FROM_EMAIL, 30 | recipient_list=recipient_list, 31 | html_message=html_message, 32 | ) 33 | 34 | def send_to_admins(self): 35 | """ 36 | Send an email notification to all admins when a job is created. 37 | """ 38 | admins_email = [ 39 | admin.email 40 | for admin in User.objects.filter(is_staff=True, is_email_verified=True) 41 | ] 42 | if not admins_email: 43 | return 44 | context = { 45 | "email_title": "New Job Created", 46 | "user_name": "Admin", 47 | "email_message": f"A new job titled {self.job_instance.job_title.title()} has been created.", 48 | "job_link": f"{settings.CURRENT_ORIGIN}/admin/job_listing_api/job/{self.job_instance.id}/", 49 | } 50 | 51 | self.__send_to_email("New Job Created", admins_email, context) 52 | 53 | def send_to_poster(self, approved=True, message=None): 54 | job_status = "approved" if approved else "rejected" 55 | context = { 56 | "email_title": f"Your Job Has Been {job_status.capitalize()}", 57 | "user_name": self.job_instance.posted_by.email, 58 | "email_message": f"Your job titled {self.job_instance.job_title.title()} has been {job_status}. ", 59 | "additional_message": message, 60 | "contact_support": f"to contact support", 61 | "job_link": f"{settings.CURRENT_ORIGIN}/admin/job_listing_api/job/{self.job_instance.id}/", 62 | "year": datetime.now().strftime("%Y"), 63 | } 64 | self.__send_to_email( 65 | f"Job {job_status.capitalize()}", 66 | [self.job_instance.posted_by.email], 67 | context, 68 | ) 69 | -------------------------------------------------------------------------------- /job_listing_api/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from taggit.managers import TaggableManager 4 | 5 | # Create your models here. 6 | 7 | 8 | class JobTypeChoice(models.TextChoices): 9 | FULL_TIME = "Full Time" 10 | PART_TIME = "Part Time" 11 | CONTRACT = "Contract" 12 | INTERNSHIP = "Internship" 13 | VOLUNTARY = "Voluntary" 14 | 15 | 16 | class JobStatus(models.TextChoices): 17 | DRAFT = "Draft" 18 | PUBLISHED = "Published" 19 | ARCHIVED = "Archived" 20 | EXPIRED = "Expired" 21 | 22 | 23 | class JobVisibility(models.TextChoices): 24 | PRIVATE = "Private" 25 | INTERNAL = "Internal" 26 | PUBLIC = "Public" 27 | FEATURED = "Featured" 28 | 29 | 30 | class SkillLevel(models.TextChoices): 31 | BEGINNER = "Beginner" 32 | INTERMIDIATE = "Intermidiate" 33 | ADVANCED = "Advanced" 34 | 35 | 36 | class JobBookmarkStatus(models.TextChoices): 37 | SAVED = "Saved" 38 | APPLIED = "Applied" 39 | INTERVIEWING = "Interviewing" 40 | REJECTED = "Rejected" 41 | OFFERED = "Offered" 42 | ARCHIVED = "Archived" 43 | 44 | 45 | 46 | 47 | 48 | class Company(models.Model): 49 | name = models.CharField(max_length=255, unique=True) 50 | location = models.CharField(max_length=255, null=True) 51 | description = models.TextField(null=True) 52 | website = models.URLField(max_length=255, null=True) 53 | 54 | def __str__(self) -> str: 55 | return self.name 56 | 57 | class Skill(models.Model): 58 | name = models.CharField(max_length=255, unique=True) 59 | 60 | def __str__(self) -> str: 61 | return self.name 62 | class Job(models.Model): 63 | company = models.ForeignKey( 64 | Company, on_delete=models.SET_NULL, null=True, to_field="name" 65 | ) 66 | company_name = models.CharField(max_length=255) 67 | job_title = models.CharField(max_length=255) 68 | job_description = models.TextField() 69 | skills = models.ManyToManyField( 70 | Skill, 71 | related_name="jobs", 72 | through="JobSkill", 73 | through_fields=("job", "skill"), 74 | db_index=True, 75 | ) 76 | tags = TaggableManager() 77 | # fields for enhanced functionality 78 | status = models.CharField( 79 | max_length=20, choices=JobStatus.choices, default=JobStatus.DRAFT 80 | ) 81 | visibility = models.CharField( 82 | max_length=20, choices=JobVisibility.choices, default=JobVisibility.PRIVATE 83 | ) 84 | 85 | # Scheduling and expiry 86 | published_at = models.DateTimeField(null=True, blank=True) 87 | application_deadline = models.DateTimeField(null=True) 88 | 89 | employment_type = models.CharField( 90 | max_length=255, choices=JobTypeChoice.choices, default=JobTypeChoice.FULL_TIME 91 | ) 92 | 93 | salary = models.DecimalField(max_digits=17, decimal_places=2, null=True) 94 | 95 | # Tracking and metrics 96 | posted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 97 | views_count = models.PositiveIntegerField(default=0) 98 | applications_count = models.PositiveIntegerField(default=0) 99 | 100 | # Versioning and audit 101 | original_job = models.ForeignKey( 102 | "self", 103 | null=True, 104 | blank=True, 105 | on_delete=models.SET_NULL, 106 | related_name="revisions", 107 | db_index=True, 108 | ) 109 | version = models.IntegerField(default=1) 110 | # existing fields 111 | created_at = models.DateTimeField(auto_now_add=True, db_index=True) 112 | slug = models.UUIDField(unique=True, db_index=True) 113 | 114 | is_approved = models.BooleanField(default=False) 115 | 116 | def __str__(self): 117 | return f"{self.job_title} at {self.company_name}" 118 | 119 | def save(self, *args, **kwargs): 120 | if self.company_name: 121 | self.company_name = self.company_name.strip().lower() 122 | super().save(*args, **kwargs) 123 | 124 | class Meta: 125 | indexes = [ 126 | models.Index(fields=["original_job", "version"]), # Composite index 127 | ] 128 | 129 | 130 | class JobSkill(models.Model): 131 | job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name="job_skills") 132 | skill = models.ForeignKey( 133 | Skill, on_delete=models.CASCADE, to_field="name" 134 | ) # References the `name` field of Skill 135 | skill_level = models.CharField( 136 | max_length=255, choices=SkillLevel.choices, default=SkillLevel.BEGINNER 137 | ) 138 | 139 | class Meta: 140 | unique_together = ("job", "skill") 141 | 142 | def __str__(self): 143 | return f"{self.job.job_title} - {self.skill.name}" 144 | 145 | 146 | class BookmarkFolder(models.Model): 147 | """Optional folder organization for job bookmarks""" 148 | 149 | folder_name = models.CharField(max_length=255) 150 | folder_description = models.TextField(null=True, blank=True) 151 | user = models.ForeignKey( 152 | settings.AUTH_USER_MODEL, 153 | on_delete=models.CASCADE, 154 | related_name="bookmark_folders", 155 | ) 156 | created_at = models.DateTimeField(auto_now_add=True) 157 | updated_at = models.DateTimeField(auto_now=True) 158 | 159 | class Meta: 160 | unique_together = ["user", "folder_name"] 161 | indexes = [models.Index(fields=["folder_name"])] 162 | 163 | def __str__(self): 164 | return self.folder_name 165 | 166 | 167 | class Bookmark(models.Model): 168 | user = models.ForeignKey( 169 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="job_bookmarks" 170 | ) 171 | job = models.ForeignKey( 172 | Job, on_delete=models.CASCADE, related_name="bookmarks" # Your Job model 173 | ) 174 | folder = models.ForeignKey( 175 | BookmarkFolder, 176 | on_delete=models.SET_NULL, 177 | null=True, 178 | blank=True, 179 | related_name="bookmarks", 180 | ) 181 | 182 | # Bookmark metadata 183 | status = models.CharField( 184 | max_length=20, 185 | choices=JobBookmarkStatus.choices, 186 | default=JobBookmarkStatus.SAVED, 187 | ) 188 | notes = models.TextField(blank=True, null=True) 189 | 190 | # Tracking 191 | created_at = models.DateTimeField(auto_now_add=True) 192 | updated_at = models.DateTimeField(auto_now=True) 193 | 194 | class Meta: 195 | unique_together = ["user", "job"] # Prevent duplicate bookmarks 196 | indexes = [ 197 | models.Index(fields=["user", "status"]), 198 | models.Index(fields=["created_at"]), 199 | ] 200 | 201 | def __str__(self): 202 | return f"{self.user.email} bookmarked this job" 203 | -------------------------------------------------------------------------------- /job_listing_api/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import SAFE_METHODS, BasePermission 2 | 3 | 4 | class IsJobPoster(BasePermission): 5 | 6 | def has_permission(self, request, view): 7 | 8 | if request.method in SAFE_METHODS: 9 | return True 10 | if view.action == "job_list": 11 | return True 12 | return request.user and request.user.is_authenticated 13 | 14 | def has_object_permission(self, request, view, obj): 15 | if request.method in SAFE_METHODS: 16 | return request.user and request.user.is_authenticated 17 | if request.method in ["DELETE", "PUT", "PATCH"]: 18 | return obj.posted_by == request.user or request.user.is_staff 19 | return False 20 | 21 | class HasObjectPermission(BasePermission): 22 | def has_permission(self, request, view): 23 | return request.user and request.user.is_authenticated 24 | 25 | def has_object_permission(self, request, view, obj): 26 | if request.method in SAFE_METHODS: 27 | return request.user and request.user.is_authenticated 28 | if request.method in ["DELETE", "PUT", "PATCH"]: 29 | return obj.user == request.user or request.user.is_staff 30 | return False -------------------------------------------------------------------------------- /job_listing_api/serializers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.core.exceptions import ValidationError 6 | from django.db import transaction 7 | from django.utils.timezone import now 8 | from rest_framework import serializers 9 | from taggit.serializers import TaggitSerializer, TagListSerializerField 10 | 11 | from common.helper import Helper 12 | from job_listing_api.models import ( 13 | Bookmark, 14 | BookmarkFolder, 15 | Company, 16 | Job, 17 | JobSkill, 18 | JobTypeChoice, 19 | Skill, 20 | ) 21 | 22 | User = get_user_model() 23 | 24 | 25 | class SkillSerializer(serializers.ModelSerializer): 26 | name = serializers.CharField(validators=[], required=True) 27 | 28 | class Meta: 29 | model = Skill 30 | exclude = ("id",) 31 | 32 | 33 | class JobSkillSerializer(serializers.ModelSerializer): 34 | skill = SkillSerializer() 35 | skill_level = serializers.CharField(required=True) 36 | 37 | class Meta: 38 | model = JobSkill 39 | exclude = ( 40 | "id", 41 | "job", 42 | ) 43 | 44 | def to_internal_value(self, data): 45 | text_fields = ["skill_level"] 46 | for field in text_fields: 47 | if field in data and data[field]: 48 | data[field] = data[field].title() 49 | return super().to_internal_value(data) 50 | 51 | 52 | class JobSerializer(TaggitSerializer, serializers.ModelSerializer, Helper): 53 | job = serializers.HyperlinkedIdentityField( 54 | view_name="job-detail", lookup_field="slug" 55 | ) 56 | job_skills = JobSkillSerializer(many=True, required=True) 57 | tags = TagListSerializerField(read_only=True) 58 | employment_type = serializers.ChoiceField(choices=JobTypeChoice.choices) 59 | company_name = serializers.CharField(required=False) 60 | original_job = serializers.HyperlinkedRelatedField( 61 | view_name="job-detail", lookup_field="slug", read_only=True 62 | ) 63 | 64 | class Meta: 65 | model = Job 66 | exclude = ("slug",) 67 | read_only_fields = [ 68 | "posted_by", 69 | "created_at", 70 | "published_at", 71 | "views_count", 72 | "applications_count", 73 | "original_job", 74 | "status", 75 | "scheduled_publish_at", 76 | "is_approved", 77 | "version", 78 | # "tags", 79 | ] 80 | 81 | def to_internal_value(self, data): 82 | text_fields = ["employment_type", "status", "visibility"] 83 | for field in text_fields: 84 | if field in data and data[field]: 85 | data[field] = data[field].title() 86 | return super().to_internal_value(data) 87 | 88 | def to_representation(self, instance): 89 | data = super().to_representation(instance) 90 | data.pop("id", None) 91 | data.pop("skills", None) 92 | # self._format_text_field(data) 93 | # self._format_list_fields(data) 94 | self._format_posted_by("posted_by", data) 95 | self._format_date_field(data) 96 | self._format_salary(data) 97 | 98 | return data 99 | 100 | def validate(self, attrs): 101 | if "salary" in attrs: 102 | attrs["salary"] = attrs["salary"] * 100 103 | 104 | for date_field in [ 105 | "application_deadline", 106 | "published_at", 107 | ]: 108 | if date_field in attrs: 109 | date_value = attrs[date_field] 110 | if isinstance(date_value, datetime) and date_value.strftime( 111 | "%Y-%m-%d" 112 | ) < now().strftime("%Y-%m-%d"): 113 | raise ValidationError( 114 | message={ 115 | date_field: f"{date_field.replace('_', ' ').capitalize()} cannot be in the past. " 116 | }, 117 | code=400, 118 | ) 119 | for field in ["job_title", "job_description"]: 120 | if field in attrs and isinstance(attrs[field], str): 121 | attrs[field] = attrs[field].strip().lower() 122 | 123 | if "company" in attrs and attrs["company"] is not None: 124 | attrs["company"] = attrs.get("company") 125 | attrs["company_name"] = attrs.get("company").name.strip().lower() 126 | else: 127 | attrs["company"] = attrs.get("company") 128 | attrs["company_name"] = ( 129 | attrs.get("company_name").strip().lower() 130 | if attrs.get("company_name") 131 | else None 132 | ) 133 | job_skills = attrs.get("job_skills", []) 134 | if not job_skills: 135 | raise ValidationError({"job_skills": "Job skills cannot be empty."}) 136 | 137 | for skill in job_skills: 138 | if not skill.get("skill"): 139 | raise ValidationError( 140 | {"job_skills": "Each job skill must have a 'skill' field."} 141 | ) 142 | if not skill.get("skill_level"): 143 | raise ValidationError( 144 | {"job_skills": "Each job skill must have a 'skill_level' field."} 145 | ) 146 | 147 | return super().validate(attrs) 148 | 149 | def create(self, validated_data): 150 | skills_data = validated_data.pop("job_skills", None) 151 | 152 | if "slug" not in validated_data: 153 | validated_data["slug"] = self.context.get("slug") 154 | if "posted_by" not in validated_data: 155 | validated_data["posted_by"] = self.context.get("posted_by") 156 | if "pubished_at" not in validated_data: 157 | validated_data["published_at"] = None 158 | 159 | with transaction.atomic(): 160 | job_instance = Job.objects.create(**validated_data) 161 | 162 | for data in skills_data: 163 | skill_data = data["skill"] 164 | skill_instance, created = Skill.objects.get_or_create( 165 | name=skill_data["name"].strip().lower() 166 | ) 167 | 168 | # Create JobSkill instance and associate with Job 169 | JobSkill.objects.create( 170 | job=job_instance, 171 | skill=skill_instance, 172 | skill_level=data["skill_level"], 173 | ) 174 | if skills_data is not None: 175 | job_instance.tags.add( 176 | *[data["skill"]["name"].strip().lower() for data in skills_data] 177 | ) 178 | 179 | return job_instance 180 | 181 | def update(self, instance, validated_data): 182 | # Extract related fields from the validated data 183 | skills_data = validated_data.pop("job_skills", None) 184 | tags_data = validated_data.pop("tags", "[]") 185 | 186 | # Create a new instance as a copy of the current instance 187 | new_job_data = { 188 | field.name: getattr(instance, field.name) 189 | for field in instance._meta.fields 190 | if field.name not in ["id", "slug", "created_at"] 191 | } 192 | 193 | # Update new_job_data with validated_data 194 | new_job_data.update(validated_data) 195 | 196 | # Update the versioning details 197 | new_job_data["version"] = instance.version + 1 198 | new_job_data["original_job"] = instance 199 | new_job_data["slug"] = self.generate_slug() 200 | 201 | # Create the new job instance 202 | with transaction.atomic(): 203 | 204 | new_instance = Job.objects.create(**new_job_data) 205 | 206 | # Update Many-to-Many fields (skills and tags) 207 | if skills_data is not None: 208 | skills_instances = [] 209 | for items in skills_data: 210 | if "skill" in items and items["skill"] is not None: 211 | skill_instance, _ = Skill.objects.get_or_create( 212 | name=items["skill"]["name"].strip().lower() 213 | ) 214 | JobSkill.objects.get_or_create( 215 | job=new_instance, 216 | skill=skill_instance, 217 | skill_level=items["skill_level"], 218 | ) 219 | skills_instances.append(skill_instance) 220 | new_instance.skills.set(skills_instances) 221 | 222 | if skills_data is not None: 223 | new_instance.tags.add( 224 | *[data["skill"]["name"].strip().lower() for data in skills_data] 225 | ) 226 | 227 | new_instance.save() 228 | 229 | return new_instance 230 | 231 | 232 | class JobApproveSerializer(serializers.Serializer): 233 | is_approved = serializers.BooleanField() 234 | message = serializers.CharField(required=False) 235 | 236 | def save(self, job_instance, **kwargs): 237 | job_instance.is_approved = self.validated_data["is_approved"] 238 | job_instance.save() 239 | return job_instance 240 | 241 | 242 | class BookmarkFolderSerializer(serializers.ModelSerializer, Helper): 243 | folder_instance = serializers.HyperlinkedIdentityField( 244 | view_name="bookmarkfolder-detail" 245 | ) 246 | 247 | class Meta: 248 | model = BookmarkFolder 249 | exclude = ["created_at", "updated_at"] 250 | read_only_fields = ["user"] 251 | 252 | def validate(self, attrs): 253 | if "folder_name" in attrs: 254 | attrs["folder_name"] = attrs["folder_name"].strip().lower() 255 | if "folder_description" in attrs: 256 | attrs["folder_description"] = attrs["folder_description"].strip().lower() 257 | return super().validate(attrs) 258 | 259 | def to_representation(self, instance): 260 | data = super().to_representation(instance) 261 | data.pop("id", None) 262 | self._format_posted_by("user", data) 263 | self._format_date_field(data) 264 | # self._format_text_field(data) 265 | 266 | return data 267 | 268 | def create(self, validated_data: dict): 269 | 270 | if "user" not in validated_data: 271 | validated_data["user"] = self.context.get("request").user 272 | 273 | with transaction.atomic(): 274 | folder_instance, created = BookmarkFolder.objects.get_or_create( 275 | **validated_data 276 | ) 277 | return folder_instance 278 | 279 | def update(self, instance, validated_data:dict): 280 | with transaction.atomic(): 281 | for attrs, value in validated_data.items(): 282 | setattr(instance, attrs, value) 283 | instance.save() 284 | return instance 285 | 286 | 287 | class BookmarkSerializer(serializers.ModelSerializer, Helper): 288 | bookmark = serializers.HyperlinkedIdentityField(view_name="bookmark-detail") 289 | job_instance = serializers.HyperlinkedRelatedField( 290 | view_name="job-detail", lookup_field="slug", source="job", read_only=True 291 | ) 292 | 293 | class OverrideQuery(serializers.HyperlinkedRelatedField): 294 | 295 | def get_queryset(self): 296 | request = self.context.get("request") 297 | if request and hasattr(request, "user"): 298 | return BookmarkFolder.objects.filter(user=request.user) 299 | return BookmarkFolder.objects.none() 300 | 301 | folder_instance = OverrideQuery( 302 | view_name="bookmarkfolder-detail", 303 | source="folder", 304 | lookup_field="pk", 305 | read_only=True, 306 | ) 307 | 308 | class Meta: 309 | model = Bookmark 310 | exclude = ["created_at", "updated_at"] 311 | read_only_fields = ["user"] 312 | 313 | def validate(self, attrs): 314 | 315 | if "notes" in attrs and attrs["notes"]: 316 | attrs["notes"] = attrs["notes"].strip().lower() 317 | return super().validate(attrs) 318 | 319 | def create(self, validated_data: dict): 320 | 321 | if "user" not in validated_data: 322 | validated_data["user"] = self.context.get("request").user 323 | 324 | with transaction.atomic(): 325 | bookmark_instance, created = Bookmark.objects.get_or_create( 326 | **validated_data 327 | ) 328 | return bookmark_instance 329 | 330 | def update(self, instance, validated_data:dict): 331 | with transaction.atomic(): 332 | for attrs, value in validated_data.items(): 333 | setattr(instance, attrs, value) 334 | instance.save() 335 | return instance 336 | 337 | def to_representation(self, instance): 338 | data = super().to_representation(instance) 339 | data.pop("id", None) 340 | self._format_posted_by("user", data) 341 | self._format_date_field(data) 342 | # self._format_text_field(data) 343 | # self._format_job_instance("job", data, self.context.get("request")) 344 | return data 345 | 346 | def to_internal_value(self, data): 347 | text_fields = ["status"] 348 | for field in text_fields: 349 | if field in data and data[field]: 350 | data[field] = data[field].title() 351 | return super().to_internal_value(data) 352 | 353 | 354 | class CompanySerializer(serializers.ModelSerializer): 355 | name = serializers.CharField(validators=[]) 356 | 357 | class Meta: 358 | model = Company 359 | fields = "__all__" 360 | -------------------------------------------------------------------------------- /job_listing_api/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | 4 | from .email import JobNotificationEmail 5 | from .models import Job 6 | 7 | @receiver(post_save, sender=Job) 8 | def send_notification(sender, instance, created, **kwargs): 9 | if instance.version > 1: 10 | return 11 | if created: 12 | 13 | try: 14 | JobNotificationEmail(instance).send_to_admins() 15 | except Exception as e: 16 | raise Exception(str(e)) 17 | -------------------------------------------------------------------------------- /job_listing_api/tests.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from rest_framework.test import APITransactionTestCase 3 | from rest_framework_simplejwt.tokens import AccessToken 4 | 5 | from authentication.models import User 6 | 7 | from .models import Job 8 | 9 | 10 | class JobAddingTestCase(APITransactionTestCase): 11 | def setUp(self): 12 | self.job_path = reverse("job-list") 13 | 14 | # Create a test user and generate a token 15 | self.user = User.objects.create_user( 16 | email="test@gmail.com", password="password123", is_superuser=True 17 | ) 18 | self.token = str( 19 | AccessToken.for_user(self.user) 20 | ) # Generate JWT token for the test user 21 | 22 | def test_adding_success(self): 23 | # Add the Authorization header with the Bearer token 24 | self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") 25 | response = self.client.post( 26 | self.job_path, 27 | data={ 28 | "title": "Software Developer", 29 | "company": "Tech Solutions Inc.", 30 | "description": "We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 31 | "location": "New York, USA", 32 | "employment_type": "Full Time", 33 | "skills": [ 34 | {"name": "Python"}, 35 | {"name": "JavaScript"}, 36 | {"name": "Django"}, 37 | {"name": "React"}, 38 | {"name": "SQL"}, 39 | ], 40 | "salary": 200000, 41 | "application_deadline": "2024-12-31", 42 | }, 43 | format="json", 44 | ) 45 | self.assertEqual(response.status_code, 201) 46 | 47 | for field in {"id", "title", "posted_by"}: 48 | self.assertTrue(field in response.data) 49 | 50 | 51 | # class JobUpdateTestCase(APITransactionTestCase): 52 | # def setUp(self): 53 | # # Create a test user and generate a token 54 | # self.user = User.objects.create_user(email="test@gmail.com", password="password123", is_superuser=True) 55 | # self.token = str(AccessToken.for_user(self.user)) # Generate JWT token for the test user 56 | # self.job = JobPosting.objects.create(title="Software Developer", 57 | # company_name="Tech Solutions Inc.", 58 | # description="We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 59 | # location= "New York, USA", 60 | # job_type= "FT", 61 | # skills_required= "Python, JavaScript, Django, React, SQL", 62 | # last_date_to_apply="2024-12-31T23:59:59Z", 63 | # is_active=False) # create one job posting for testing 64 | # self.update_path = reverse("job:job_posting_upate", kwargs={'pk': self.job.pk}) 65 | 66 | 67 | # def test_update_job(self): 68 | # # Add the Authorization header with the Bearer token 69 | # self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") 70 | # response = self.client.put( 71 | # self.update_path, data= { 72 | # "title": "Python Developer", 73 | # "company_name": "Tech Solutions Inc.", 74 | # "description": "We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 75 | # "location": "New York, USA", 76 | # "job_type": "PT", 77 | # "skills_required": "Python, JavaScript, Django, React, SQL", 78 | # "last_date_to_apply": "2024-12-31T23:59:59Z", 79 | # "is_active": False 80 | # } 81 | # ) 82 | # self.assertEqual( 83 | # response.data["title"], "Python Developer" 84 | # ) 85 | # self.assertEqual( 86 | # response.data["job_type"], "PT" 87 | # ) 88 | 89 | # class JobDeleteTestCase(APITransactionTestCase): 90 | # def setUp(self): 91 | # # Create a test user and generate a token 92 | # self.user = User.objects.create_user(email="test@gmail.com", password="password123", is_superuser=True) 93 | # self.token = str(AccessToken.for_user(self.user)) # Generate JWT token for the test user 94 | # self.job = JobPosting.objects.create(title="Software Developer", 95 | # company_name="Tech Solutions Inc.", 96 | # description="We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 97 | # location= "New York, USA", 98 | # job_type= "FT", 99 | # skills_required= "Python, JavaScript, Django, React, SQL", 100 | # last_date_to_apply="2024-12-31T23:59:59Z", 101 | # is_active=False) # create one job posting for testing 102 | # self.delete_path = reverse("job:job_posting_delete", kwargs={'pk': self.job.pk}) 103 | 104 | 105 | # def test_delete_job(self): 106 | # # Add the Authorization header with the Bearer token 107 | # self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") 108 | # response = self.client.delete( 109 | # self.delete_path 110 | # ) 111 | # self.assertEqual( 112 | # response.data["message"], "Job posting deleted successfully" 113 | # ) 114 | 115 | 116 | # class JobListTestCase(APITransactionTestCase): 117 | # def setUp(self): 118 | # # Create a test user and generate a token to post job. though not need to view the jobs 119 | # self.user = User.objects.create_user(email="test@gmail.com", password="password123", is_superuser=True) 120 | # self.token = str(AccessToken.for_user(self.user)) # Generate JWT token for the test user 121 | # self.job = JobPosting.objects.create(title="Software Developer", 122 | # company_name="Tech Solutions Inc.", 123 | # description="We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 124 | # location= "New York, USA", 125 | # job_type= "FT", 126 | # skills_required= "Python, JavaScript, Django, React, SQL", 127 | # last_date_to_apply="2024-12-31T23:59:59Z", 128 | # is_active=False) # create one job posting for testing 129 | # self.list_path = reverse("job:job_posting_list",) 130 | 131 | 132 | # def test_list_job(self): 133 | # # No credentials needed to view jobs 134 | # response = self.client.get( 135 | # self.list_path 136 | # ) 137 | # # check that the list returned is not empty 138 | # self.assertNotEqual( 139 | # response.data[0], [] 140 | # ) 141 | 142 | 143 | # class JobDetailTestCase(APITransactionTestCase): 144 | # def setUp(self): 145 | # # Create a test user and generate a token 146 | # self.user = User.objects.create_user(email="test@gmail.com", password="password123", is_superuser=True) 147 | # self.token = str(AccessToken.for_user(self.user)) # Generate JWT token for the test user 148 | # self.job = JobPosting.objects.create(title="Software Developer", 149 | # company_name="Tech Solutions Inc.", 150 | # description="We are looking for a skilled software developer to join our team. The ideal candidate will have experience with Python, JavaScript, and web development frameworks.", 151 | # location= "New York, USA", 152 | # job_type= "FT", 153 | # skills_required= "Python, JavaScript, Django, React, SQL", 154 | # last_date_to_apply="2024-12-31T23:59:59Z", 155 | # is_active=False) # create one job posting for testing 156 | # self.detail_path = reverse("job:job_posting_detail", kwargs={'slug': self.job.slug}) 157 | 158 | 159 | # def test_job_detail(self): 160 | # # Add the Authorization header with the Bearer token 161 | # response = self.client.get( 162 | # self.detail_path 163 | # ) 164 | # for field in {"id", "title", "slug"}: 165 | # self.assertTrue(field in response.data) 166 | -------------------------------------------------------------------------------- /job_listing_api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from rest_framework.routers import DefaultRouter 3 | 4 | from rest_framework_simplejwt.views import TokenObtainPairView 5 | 6 | from job_listing_api.views import ( 7 | BookmarkFolderViewset, 8 | BookmarkViewset, 9 | JobApproveView, 10 | JobViewset, 11 | ) 12 | 13 | router = DefaultRouter() 14 | 15 | router.register(r"job", JobViewset) 16 | router.register(r"bookmark", BookmarkViewset) 17 | router.register(r"bookmark-folders", BookmarkFolderViewset) 18 | 19 | 20 | urlpatterns = [ 21 | path("", include(router.urls)), 22 | path("job/approve//", JobApproveView.as_view(), name="job-approve"), 23 | path("login/", TokenObtainPairView.as_view()) 24 | ] 25 | -------------------------------------------------------------------------------- /job_listing_api/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.db.transaction import atomic 3 | from django.utils import timezone 4 | from django_filters.rest_framework import DjangoFilterBackend 5 | from rest_framework import viewsets 6 | from rest_framework.authentication import SessionAuthentication 7 | from rest_framework.decorators import action 8 | from rest_framework.exceptions import MethodNotAllowed 9 | from rest_framework.filters import OrderingFilter, SearchFilter 10 | from rest_framework.permissions import IsAuthenticated, IsAdminUser 11 | from rest_framework.response import Response 12 | from rest_framework.views import APIView 13 | from rest_framework_simplejwt.authentication import JWTAuthentication 14 | 15 | from common.filterset import JobFilterset 16 | from common.helper import Helper 17 | 18 | from .email import JobNotificationEmail 19 | from .models import Bookmark, BookmarkFolder, Job 20 | from .permissions import IsJobPoster, HasObjectPermission 21 | from .serializers import ( 22 | BookmarkFolderSerializer, 23 | BookmarkSerializer, 24 | JobApproveSerializer, 25 | JobSerializer, 26 | ) 27 | 28 | # Create your views here. 29 | 30 | 31 | class JobViewset(viewsets.ModelViewSet, Helper): 32 | queryset = Job.objects.all().order_by("-created_at") 33 | serializer_class = JobSerializer 34 | authentication_classes = [SessionAuthentication, JWTAuthentication] 35 | permission_classes = [IsJobPoster] 36 | filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] 37 | search_fields = [ 38 | "job_title", 39 | "employment_type", 40 | "tags__name", 41 | ] 42 | ordering_fields = [ 43 | "job_title", 44 | "employment_type", 45 | "tags__name", 46 | "salary", 47 | ] 48 | ordering = ["job_title"] 49 | filterset_class = JobFilterset 50 | lookup_field = "slug" 51 | 52 | def list(self, request, *args, **kwargs): 53 | raise MethodNotAllowed(method="get") 54 | 55 | def filter_queryset(self, queryset): 56 | """ 57 | Apply additional search filters while maintaining queryset ordering. 58 | """ 59 | search_param = self.request.query_params.get("search") 60 | 61 | if search_param: 62 | search_terms = [ 63 | term.strip().lower() for term in search_param.split(",") if term.strip() 64 | ] 65 | skill_filter = Q() 66 | for term in search_terms: 67 | skill_filter |= ( 68 | Q(job_title__icontains=term) 69 | | Q(employment_type__icontains=term) 70 | | Q(tags__name__icontains=term) 71 | | Q(job_skills__skill_level__icontains=term) 72 | ) 73 | queryset = queryset.filter(skill_filter).distinct() 74 | 75 | return super().filter_queryset(queryset) 76 | 77 | @atomic() 78 | def create(self, request, *args, **kwargs): 79 | slug = self.generate_slug() 80 | posted_by = self.request.user if self.request.user.is_authenticated else "" 81 | 82 | serializer = self.get_serializer( 83 | data=request.data, 84 | context={ 85 | "request": request, 86 | "slug": slug, 87 | "posted_by": posted_by, 88 | }, 89 | ) 90 | serializer.is_valid(raise_exception=True) 91 | created_instance = serializer.create(serializer.validated_data) 92 | response_data = self.get_serializer( 93 | created_instance, context={"request": request} 94 | ).data 95 | 96 | return Response(response_data, status=201) 97 | 98 | @action( 99 | methods=["get"], 100 | detail=False, 101 | url_path="job-list", 102 | url_name="job-list", 103 | ) 104 | def job_list(self, request, *args, **kwargs): 105 | queryset = self.filter_queryset(self.get_queryset()) 106 | 107 | page = self.paginate_queryset(queryset) 108 | if page is not None: 109 | serializer = self.get_serializer(page, many=True) 110 | return self.get_paginated_response(serializer.data) 111 | 112 | serializer = self.get_serializer(queryset, many=True) 113 | return Response(serializer.data) 114 | 115 | def retrieve(self, request, *args, **kwargs): 116 | instance = self.get_object() 117 | serializer = self.get_serializer(instance) 118 | return Response(serializer.data) 119 | 120 | @atomic() 121 | def update(self, request, *args, **kwargs): 122 | instance = self.get_object() 123 | serializer = self.get_serializer( 124 | instance, data=request.data, context={"request": request} 125 | ) 126 | serializer.is_valid(raise_exception=True) 127 | updated_instance = serializer.update(instance, serializer.validated_data) 128 | response_data = self.get_serializer( 129 | updated_instance, context={"request": request} 130 | ).data 131 | return Response(response_data) 132 | 133 | @atomic() 134 | def partial_update(self, request, *args, **kwargs): 135 | kwargs["partial"] = True 136 | return self.update(request, *args, **kwargs) 137 | 138 | @atomic() 139 | def destroy(self, request, *args, **kwargs): 140 | instance = self.get_object() 141 | self.perform_destroy(instance) 142 | return Response(status=204) 143 | 144 | 145 | class JobApproveView(APIView): 146 | serializer_class = JobApproveSerializer 147 | permission_classes = [IsAdminUser] 148 | 149 | @atomic() 150 | def post(self, request, slug): 151 | 152 | job_instance = Job.objects.get(slug=slug) 153 | serializer = self.serializer_class(data=request.data) 154 | serializer.is_valid(raise_exception=True) 155 | updated_job = serializer.save(job_instance=job_instance) 156 | status = "approved" if updated_job.is_approved else "rejected" 157 | message = ( 158 | serializer.data.get("message") if serializer.data.get("message") else None 159 | ) 160 | 161 | JobNotificationEmail(updated_job).send_to_poster( 162 | updated_job.is_approved, message 163 | ) 164 | return Response( 165 | { 166 | "status": status.title(), 167 | "message": message, 168 | "is_approved": updated_job.is_approved, 169 | }, 170 | status=200, 171 | ) 172 | 173 | 174 | class BookmarkFolderViewset(viewsets.ModelViewSet): 175 | queryset = BookmarkFolder.objects.all().order_by("-created_at") 176 | serializer_class = BookmarkFolderSerializer 177 | permission_classes = [HasObjectPermission] 178 | 179 | def get_queryset(self): 180 | if self.action == "list" or self.action == "retrieve": 181 | return BookmarkFolder.objects.filter(user=self.request.user) 182 | else: 183 | return self.serializer_class 184 | 185 | @atomic() 186 | def create(self, request, *args, **kwargs): 187 | serializer = self.get_serializer( 188 | data=request.data, context={"request": request} 189 | ) 190 | serializer.is_valid(raise_exception=True) 191 | folder_instance = serializer.create(serializer.validated_data) 192 | response_data = self.get_serializer( 193 | folder_instance, context={"request": request} 194 | ).data 195 | return Response(response_data) 196 | 197 | @atomic() 198 | def update(self, request, *args, **kwargs): 199 | instance = self.get_object() 200 | serializer = self.get_serializer( 201 | instance, data=request.data, context={"request": request} 202 | ) 203 | serializer.is_valid(raise_exception=True) 204 | updated_instance = serializer.update(instance, serializer.validated_data) 205 | response_data = self.get_serializer( 206 | updated_instance, context={"request": request} 207 | ).data 208 | return Response(response_data) 209 | 210 | @atomic() 211 | def partial_update(self, request, *args, **kwargs): 212 | kwargs["partial"] = True 213 | return self.update(request, *args, **kwargs) 214 | 215 | @atomic() 216 | def destroy(self, request, *args, **kwargs): 217 | instance = self.get_object() 218 | self.perform_destroy(instance) 219 | return Response(status=204) 220 | 221 | 222 | class BookmarkViewset(viewsets.ModelViewSet): 223 | queryset = Bookmark.objects.all() 224 | permission_classes = [HasObjectPermission] 225 | serializer_class = BookmarkSerializer 226 | 227 | def get_queryset(self): 228 | return Bookmark.objects.filter(user=self.request.user) 229 | 230 | @atomic() 231 | def partial_update(self, request, *args, **kwargs): 232 | kwargs["partial"] = True 233 | return self.update(request, *args, **kwargs) 234 | 235 | @atomic() 236 | def destroy(self, request, *args, **kwargs): 237 | instance = self.get_object() 238 | self.perform_destroy(instance) 239 | return Response(status=204) 240 | 241 | def retrieve(self, request, *args, **kwargs): 242 | instance = self.get_object() 243 | serializer = self.get_serializer(instance) 244 | return Response(serializer.data) 245 | 246 | @atomic() 247 | def update(self, request, *args, **kwargs): 248 | instance = self.get_object() 249 | serializer = self.get_serializer( 250 | instance, data=request.data, context={"request": request} 251 | ) 252 | serializer.is_valid(raise_exception=True) 253 | updated_instance = serializer.update(instance, serializer.validated_data) 254 | response_data = self.get_serializer( 255 | updated_instance, context={"request": request} 256 | ).data 257 | return Response(response_data) 258 | 259 | @atomic() 260 | def create(self, request, *args, **kwargs): 261 | serializer = self.get_serializer( 262 | data=request.data, context={"request": request} 263 | ) 264 | serializer.is_valid(raise_exception=True) 265 | bookmark_instance = serializer.create(serializer.validated_data) 266 | response_data = self.get_serializer( 267 | bookmark_instance, context={"request": request} 268 | ).data 269 | return Response(response_data) 270 | -------------------------------------------------------------------------------- /knowledge_base_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/knowledge_base_api/__init__.py -------------------------------------------------------------------------------- /knowledge_base_api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import UserUpload 4 | 5 | # Register your models here. 6 | 7 | 8 | @admin.action(description="Approve selected uploads") 9 | def approve_uploads(modeladmin, request, queryset): 10 | for upload in queryset: 11 | upload.update_status(UserUpload.Status.APPROVED) 12 | 13 | 14 | @admin.action(description="Reject selected uploads") 15 | def reject_uploads(modeladmin, request, queryset): 16 | for upload in queryset: 17 | upload.update_status(UserUpload.Status.REJECTED) 18 | 19 | 20 | @admin.register(UserUpload) 21 | class UserUploadAdmin(admin.ModelAdmin): 22 | list_display = ("user", "upload_type", "file", "status", "created_at") 23 | actions = [approve_uploads, reject_uploads] 24 | -------------------------------------------------------------------------------- /knowledge_base_api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class KnowledgeBaseApiConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "knowledge_base_api" 7 | -------------------------------------------------------------------------------- /knowledge_base_api/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from django.utils import timezone 5 | from taggit.managers import TaggableManager 6 | 7 | # Create your models here. 8 | 9 | 10 | User = settings.AUTH_USER_MODEL 11 | 12 | 13 | class PublishManager(models.Manager): 14 | def get_queryset(self): 15 | """ 16 | Returns a queryset of approved uploads. 17 | """ 18 | return super().get_queryset().filter(status="APPROVED") 19 | 20 | 21 | class UserUpload(models.Model): 22 | 23 | tags = TaggableManager() 24 | 25 | UPLOAD_TYPE = [ 26 | ("PDF", "PDF Document"), 27 | ("EBOOK", "Ebook"), 28 | ("IMAGE", "image"), 29 | ] 30 | 31 | class Status(models.TextChoices): 32 | PENDING = "PENDING", "Pending" 33 | APPROVED = "APPROVED", "Approved" 34 | REJECTED = "REJECTED", "Rejected" 35 | 36 | def validate_file_extension(value): 37 | """ 38 | Validates the file extension of the given file. 39 | """ 40 | allowed_files = ["pdf", "doc", "docx", "jpg", "jpeg", "png"] 41 | file_extension = value.name.split(".")[-1].lower() 42 | if file_extension not in allowed_files: 43 | raise ValidationError( 44 | f'Unsupported file type. Allowed types: {", ".join(allowed_files)}' 45 | ) 46 | 47 | user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="uploads") 48 | upload_type = models.CharField(max_length=10, choices=UPLOAD_TYPE) 49 | file = models.FileField( 50 | upload_to="uploads/%Y/%m/%d/", validators=[validate_file_extension] 51 | ) 52 | description = models.TextField(blank=True, null=True) 53 | created_at = models.DateTimeField(auto_now_add=True) 54 | published_at = models.DateTimeField(default=timezone.now) 55 | status = models.CharField( 56 | max_length=10, choices=Status.choices, default=Status.PENDING 57 | ) 58 | 59 | class Meta: 60 | ordering = ["-created_at"] 61 | indexes = [ 62 | models.Index(fields=["-created_at"]), 63 | ] 64 | 65 | published = PublishManager() 66 | objects = models.Manager() 67 | 68 | class Meta: 69 | ordering = ["-created_at"] 70 | indexes = [ 71 | models.Index(fields=["-created_at"]), 72 | ] 73 | 74 | def __str__(self): 75 | """ 76 | Returns a string representation of the UserUpload instance, 77 | including the username, upload type, and file name. 78 | """ 79 | return f"{self.user.username} - {self.upload_type} - {self.file.name}" 80 | 81 | def update_file_status(self, new_status): 82 | """ 83 | Updates the status of the file. 84 | 85 | The possible status transitions are: 86 | PENDING -> APPROVED or REJECTED 87 | APPROVED -> None 88 | REJECTED -> None 89 | """ 90 | valid_status = { 91 | self.Status.PENDING: [self.Status.APPROVED, self.Status.REJECTED], 92 | self.Status.APPROVED: [], 93 | self.Status.REJECTED: [], 94 | } 95 | 96 | if new_status not in valid_status[self.status]: 97 | raise ValidationError( 98 | f"Cannot change status from {self.status} to {new_status}" 99 | ) 100 | 101 | if new_status == self.Status.REJECTED: 102 | self.file.delete() 103 | 104 | self.status = new_status 105 | self.save() 106 | -------------------------------------------------------------------------------- /knowledge_base_api/permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework.permissions import BasePermission 3 | 4 | 5 | class CustomPermission(BasePermission): 6 | 7 | def has_permission(self, request, view): 8 | User = get_user_model() 9 | return request.user and request.user.is_authenticated 10 | -------------------------------------------------------------------------------- /knowledge_base_api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import UserUpload 4 | 5 | 6 | class UserUploadSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = UserUpload 9 | fields = [ 10 | "id", 11 | "upload_type", 12 | "file", 13 | "description", 14 | "created_at", 15 | "published_at", 16 | "status", 17 | "tags", 18 | ] 19 | read_only_fields = ["id", "created_at", "published_at", "status"] 20 | 21 | def validate_file(self, value): 22 | """ 23 | Validate the file extension. 24 | """ 25 | UserUpload.validate_file_extension(value) 26 | return value 27 | -------------------------------------------------------------------------------- /knowledge_base_api/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /knowledge_base_api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "knowledge_base_api_v1" 6 | 7 | 8 | urlpatterns = [ 9 | # Methods: 10 | # GET : List all the user uploads, 11 | # POST : Create a new user upload 12 | path( 13 | "api/uploads/", 14 | views.UserUploadListCreateViewAPIView.as_view(), 15 | name="user_upload_list_create", 16 | ), 17 | # Methods: 18 | # GET : Retrieve a user upload using ID, 19 | # PUT : Update a user upload using ID, 20 | # DELETE : Delete a user upload 21 | path( 22 | "api/uploads//", 23 | views.UserUploadDetailAPIView.as_view(), 24 | name="user_upload_detail", 25 | ), 26 | # Methods: 27 | # GET : List all published uploads with status 'APPROVED', 28 | path( 29 | "api/uploads/published/", 30 | views.PublishedUploadsListAPIView.as_view(), 31 | name="approved_uploads_list", 32 | ), 33 | # Methods: 34 | # PATCH : Update the status of a user upload (PENDING -> APPROVED or REJECTED) 35 | # note: (only available to admin users) 36 | path( 37 | "api/uploads//status/", 38 | views.UpdateUploadStatusAPIView.as_view(), 39 | name="update_upload_status", 40 | ), 41 | # Methods: 42 | # GET : List all the uploads of an authenticated user 43 | path( 44 | "api/uploads/mine/", 45 | views.UserUploadsListAPIView.as_view(), 46 | name="user_uploads_list", 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /knowledge_base_api/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from rest_framework import generics 3 | from rest_framework.exceptions import ValidationError 4 | from rest_framework.permissions import IsAdminUser 5 | from rest_framework.response import Response 6 | from rest_framework.views import APIView 7 | 8 | from .models import UserUpload 9 | from .permissions import CustomPermission 10 | from .serializers import UserUploadSerializer 11 | 12 | # Create your views here. 13 | 14 | 15 | class UserUploadListCreateViewAPIView(generics.ListCreateAPIView): 16 | queryset = UserUpload.objects.all() 17 | serializer_class = UserUploadSerializer 18 | permission_classes = [CustomPermission] 19 | ordering_fields = ["created_at", "published_at"] 20 | 21 | def perform_create(self, serializer): 22 | """ 23 | Associates the authenticated user with the UserUpload instance 24 | being created and saves it to the database. 25 | """ 26 | serializer.save(user=self.request.user) 27 | 28 | 29 | class UserUploadDetailAPIView(generics.RetrieveUpdateDestroyAPIView): 30 | queryset = UserUpload.objects.all() 31 | serializer_class = UserUploadSerializer 32 | permission_classes = [CustomPermission] 33 | 34 | def get_queryset(self): 35 | return super().get_queryset().filter(user=self.request.user) 36 | 37 | 38 | class PublishedUploadsListAPIView(generics.ListAPIView): 39 | queryset = UserUpload.published.all() 40 | serializer_class = UserUploadSerializer 41 | permission_classes = [CustomPermission] 42 | 43 | 44 | class UpdateUploadStatusAPIView(APIView): 45 | permission_classes = [IsAdminUser] 46 | 47 | def patch(self, request, pk): 48 | upload = get_object_or_404(UserUpload, pk=pk) 49 | new_status = request.data.get("status") 50 | try: 51 | upload.update_file_status(new_status) 52 | return Response( 53 | {"status": "success", "message": "Status updated successfully."} 54 | ) 55 | except ValidationError as e: 56 | return Response({"status": "error", "message": str(e)}, status=400) 57 | 58 | 59 | class UserUploadsListAPIView(generics.ListAPIView): 60 | serializer_class = UserUploadSerializer 61 | permission_classes = [CustomPermission] 62 | 63 | def get_queryset(self): 64 | return UserUpload.objects.filter(user=self.request.user) 65 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pynigeriaBackend.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /pynigeriaBackend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/pynigeriaBackend/__init__.py -------------------------------------------------------------------------------- /pynigeriaBackend/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for pynigeriaBackend project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pynigeriaBackend.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /pynigeriaBackend/exception_handler.py: -------------------------------------------------------------------------------- 1 | from django.db import IntegrityError 2 | from rest_framework import status 3 | from rest_framework.exceptions import AuthenticationFailed, Throttled, ValidationError 4 | from rest_framework.response import Response 5 | from rest_framework.views import exception_handler 6 | 7 | 8 | def pynigeria_exception_handler(exc, context): 9 | response = exception_handler(exc, context) 10 | if response is None: 11 | response = Response( 12 | {"detail": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR 13 | ) 14 | if isinstance(exc, (ValidationError, AuthenticationFailed)): 15 | error_list = [] 16 | try: 17 | for key, value in exc.get_full_details().items(): 18 | try: 19 | for error in value: 20 | if error["code"] == "required": 21 | error_list.append(f"{key.title()} field is required.") 22 | elif error["code"] == "blank": 23 | error_list.append(f"{key.title()} field cannot be blank.") 24 | elif error["code"] == "unique": 25 | error_list.append(error["message"].capitalize()) 26 | elif error["code"] == "invalid_choice": 27 | error_list.append(error["message"]) 28 | elif error["code"] == "invalid": 29 | error_list.append(error["message"].capitalize()) 30 | else: 31 | error_list.append(error) 32 | except: 33 | error_list.append(value["message"]) 34 | if len(error_list) == 1: 35 | error_list = error_list[0] 36 | 37 | response.data = {"detail": error_list} 38 | except: 39 | try: 40 | response.data = {"detail": str(exc.detail["messages"][0]["message"])} 41 | except: 42 | response.data = {"detail": str(exc.detail)} 43 | response.status_code = status.HTTP_400_BAD_REQUEST 44 | elif isinstance(exc, IntegrityError): 45 | response.status_code = status.HTTP_409_CONFLICT 46 | elif isinstance(exc, Throttled): 47 | response.status_code = status.HTTP_429_TOO_MANY_REQUESTS 48 | 49 | return response 50 | -------------------------------------------------------------------------------- /pynigeriaBackend/pipeline.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import AuthenticationFailed 2 | from social_core.pipeline.user import USER_FIELDS 3 | 4 | 5 | def custom_create_user(backend, details, user=None, *args, **kwargs): 6 | # Check for existing user with verified email 7 | if user: 8 | if not user.is_email_verified: 9 | raise AuthenticationFailed( 10 | "This account has not been verified. Check your email for a verification link." 11 | ) 12 | else: 13 | return {"is_new": False} # existing user account 14 | fields = { 15 | name: kwargs.get(name, details.get(name)) 16 | for name in backend.setting("USER_FIELDS", USER_FIELDS) 17 | } 18 | if not fields: 19 | return 20 | fields["is_email_verified"] = True 21 | user = backend.strategy.create_user(**fields) 22 | return {"is_new": True, "user": user} 23 | -------------------------------------------------------------------------------- /pynigeriaBackend/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotenv import load_dotenv 5 | 6 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 7 | BASE_DIR = Path(__file__).resolve().parent.parent 8 | 9 | env_file = BASE_DIR / ".env" 10 | if env_file.exists(): 11 | load_dotenv(env_file, override=True) 12 | else: 13 | print("No env file detected.") 14 | exit(code=5000) 15 | 16 | # SECURITY WARNING: keep the secret key used in production secret! 17 | SECRET_KEY = os.getenv("SECRET_KEY_VALUE", default="default") 18 | 19 | # SECURITY WARNING: don't run with debug turned on in production! 20 | DEBUG = os.getenv("DEBUG_VALUE", "true").lower() == "true" 21 | 22 | ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS_VALUE", "127.0.0.1").split(",") # Use commas to seperate muliple host values 23 | 24 | # CSRF_TRUSTED_ORIGINS = os.getenv( 25 | # "CSRF_TRUSTED_ORIGINS_VALUE", "http://127.0.0.1" 26 | # ).split( 27 | # "," 28 | # ) # Same comma-value-seperation as above 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | CSRF_COOKIE_SAMESITE = 'None' 32 | CSRF_TRUSTED_ORIGINS = ['http://localhost:3000'] 33 | CSRF_COOKIE_HTTPONLY = False 34 | DEBUG = True 35 | 36 | ALLOWED_HOSTS = ["*"] 37 | 38 | # Application definition 39 | 40 | INSTALLED_APPS = [ 41 | "django.contrib.admin", 42 | "django.contrib.auth", 43 | "django.contrib.contenttypes", 44 | "django.contrib.sessions", 45 | "django.contrib.messages", 46 | "django.contrib.staticfiles", 47 | # Third-Party packages 48 | "corsheaders", 49 | "rest_framework", 50 | "rest_framework_simplejwt", 51 | "drf_spectacular", # for openapi/swagger documentation 52 | "drf_spectacular_sidecar", 53 | "django_otp", # for 2FA 54 | "django_otp.plugins.otp_totp", 55 | "django_filters", 56 | "authentication", 57 | "job_listing_api", 58 | "knowledge_base_api", 59 | "tracking", 60 | # For social auth 61 | "oauth2_provider", 62 | "social_django", 63 | "drf_social_oauth2", 64 | "taggit", 65 | ] 66 | 67 | MIDDLEWARE = [ 68 | "corsheaders.middleware.CorsMiddleware", 69 | "whitenoise.middleware.WhiteNoiseMiddleware", 70 | "django.middleware.security.SecurityMiddleware", 71 | "django.contrib.sessions.middleware.SessionMiddleware", 72 | "django.middleware.common.CommonMiddleware", 73 | "django.middleware.csrf.CsrfViewMiddleware", 74 | "django.contrib.auth.middleware.AuthenticationMiddleware", 75 | "django_otp.middleware.OTPMiddleware", # 2FA middleware 76 | "django.contrib.messages.middleware.MessageMiddleware", 77 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 78 | ] 79 | 80 | ROOT_URLCONF = "pynigeriaBackend.urls" 81 | 82 | TEMPLATES = [ 83 | { 84 | "BACKEND": "django.template.backends.django.DjangoTemplates", 85 | "DIRS": [BASE_DIR / "templates"], 86 | "APP_DIRS": True, 87 | "OPTIONS": { 88 | "context_processors": [ 89 | "django.template.context_processors.debug", 90 | "django.template.context_processors.request", 91 | "django.contrib.auth.context_processors.auth", 92 | "django.contrib.messages.context_processors.messages", 93 | ], 94 | }, 95 | }, 96 | ] 97 | 98 | WSGI_APPLICATION = "pynigeriaBackend.wsgi.application" 99 | 100 | 101 | # Database 102 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 103 | 104 | DATABASES = { 105 | "default": { 106 | "ENGINE": "django.db.backends.sqlite3", 107 | "NAME": BASE_DIR / "db.sqlite3", 108 | } 109 | } 110 | 111 | 112 | # Password validation 113 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 114 | 115 | AUTH_PASSWORD_VALIDATORS = [ 116 | { 117 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 121 | }, 122 | { 123 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 124 | }, 125 | { 126 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 127 | }, 128 | ] 129 | 130 | 131 | # Internationalization 132 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 133 | 134 | LANGUAGE_CODE = "en-us" 135 | 136 | TIME_ZONE = "UTC" 137 | 138 | USE_I18N = True 139 | 140 | USE_TZ = True 141 | 142 | 143 | # Static files (CSS, JavaScript, Images) 144 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 145 | 146 | STATIC_URL = "static/" 147 | STATIC_ROOT = BASE_DIR / "staticfiles" 148 | 149 | MEDIA_URL = 'media/' 150 | MEDIA_ROOT = BASE_DIR / 'media' 151 | 152 | STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 153 | 154 | # Default primary key field type 155 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 156 | 157 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 158 | 159 | 160 | AUTH_USER_MODEL = "authentication.User" 161 | 162 | REST_FRAMEWORK = { 163 | "REST_FRAMEWORK_THROTTLE_CLASSES": [ 164 | "rest_framework.throttling.AnonRateThrottle", 165 | "rest_framework.throttling.UserRateThrottle", 166 | ], 167 | "DEFAULT_THROTTLE_RATES": { 168 | "anon": "15/min", 169 | "user": "30/min", 170 | }, 171 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 172 | # "EXCEPTION_HANDLER": "pynigeriaBackend.exception_handler.pynigeria_exception_handler", 173 | "DEFAULT_PERMISSION_CLASSES": [ 174 | "rest_framework.permissions.IsAuthenticated", 175 | # "rest_framework.permissions.AllowAny" 176 | ], 177 | } 178 | 179 | SPECTACULAR_SETTINGS = { 180 | "TITLE": "PYNIGERIA BACKEND API", 181 | "VERSION": "1.0.0", 182 | "SERVE_INCLUDE_SCHEMA": False, 183 | "SWAGGER_UI_DIST": "SIDECAR", 184 | "SWAGGER_UI_FAVICON_HREF": "SIDECAR", 185 | "REDOC_DIST": "SIDECAR", 186 | } 187 | 188 | # Email settings 189 | CURRENT_ORIGIN = os.getenv("CURRENT_ORIGIN_VALUE") 190 | SENDER_EMAIL = os.getenv("SENDER_EMAIL_VALUE") 191 | EMAIL_BACKEND = os.getenv("EMAIL_BACKEND_VALUE") 192 | EMAIL_HOST = os.getenv("EMAIL_HOST_VALUE") 193 | EMAIL_PORT = os.getenv("EMAIL_PORT_VALUE") 194 | EMAIL_USE_TLS = True 195 | EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER_VALUE") 196 | EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD_VALUE") 197 | 198 | # 2FA TOTP settings 199 | OTP_TOTP_ISSUER = "pynigeria" 200 | TAGGIT_CASE_INSENSITIVE = True 201 | CORS_ALLOW_ALL_ORIGINS = True 202 | 203 | -------------------------------------------------------------------------------- /pynigeriaBackend/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for pynigeriaBackend project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.conf import settings 19 | from django.conf.urls.static import static 20 | from django.contrib import admin 21 | from django.urls import include, path 22 | from drf_spectacular.views import ( 23 | SpectacularAPIView, 24 | SpectacularRedocView, 25 | SpectacularSwaggerView, 26 | ) 27 | 28 | urlpatterns = [ 29 | path("admin/", admin.site.urls), 30 | path("api/v1/", include("job_listing_api.urls")), 31 | path( 32 | "api/v1/authentication/", 33 | include("authentication.urls", namespace="authentication_v1"), 34 | ), 35 | # path("api/v1/jobs/", include("job_listing_api.urls", namespace="job_posting_v1")), 36 | path( 37 | "api/v1/knowledge-base/", 38 | include("knowledge_base_api.urls", namespace="knowledge_base_api_v1"), 39 | ), 40 | # Schema and documentation below 41 | path("api/schema/", SpectacularAPIView.as_view(), name="schema"), 42 | path( 43 | "api/schema/swagger-ui/", 44 | SpectacularSwaggerView.as_view(url_name="schema"), 45 | name="swagger-ui", 46 | ), 47 | path( 48 | "api/schema/redoc/", 49 | SpectacularRedocView.as_view(url_name="schema"), 50 | name="redoc", 51 | ), 52 | ] 53 | 54 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 55 | -------------------------------------------------------------------------------- /pynigeriaBackend/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pynigeriaBackend project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pynigeriaBackend.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pynigeria-backend" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["SimpleNiQue "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | asgiref = "3.8.1" 12 | attrs = "24.2.0" 13 | black = "24.10.0" 14 | certifi = "2024.8.30" 15 | cffi = "1.17.1" 16 | charset-normalizer = "3.4.0" 17 | click = "8.1.7" 18 | cryptography = "44.0.0" 19 | defusedxml = "0.8.0rc2" 20 | django = "5.1.4" 21 | django-cors-headers = "4.6.0" 22 | django-filter = "24.3" 23 | django-oauth-toolkit = "3.0.1" 24 | django-otp = "1.5.4" 25 | djangorestframework = "3.14.0" 26 | djangorestframework-simplejwt = "5.3.1" 27 | drf-social-oauth2 = "3.1.0" 28 | drf-spectacular = "0.28.0" 29 | drf-spectacular-sidecar = "2024.12.1" 30 | idna = "3.10" 31 | inflection = "0.5.1" 32 | jsonschema = "4.23.0" 33 | jsonschema-specifications = "2024.10.1" 34 | jwcrypto = "1.5.6" 35 | mypy-extensions = "1.0.0" 36 | nanoid = "2.0.0" 37 | oauthlib = "3.2.2" 38 | packaging = "24.2" 39 | pathspec = "0.12.1" 40 | pillow = "11.0.0" 41 | platformdirs = "4.3.6" 42 | pycparser = "2.22" 43 | pyjwt = "2.10.1" 44 | pyotp = "2.9.0" 45 | python-dotenv = "1.0.1" 46 | python3-openid = "3.2.0" 47 | pyyaml = "6.0.2" 48 | qrcode = "8.0" 49 | referencing = "0.35.1" 50 | requests = "2.32.3" 51 | requests-oauthlib = "2.0.0" 52 | rpds-py = "0.22.3" 53 | social-auth-app-django = "5.4.2" 54 | social-auth-core = "4.5.4" 55 | sqlparse = "0.5.2" 56 | typing-extensions = "4.12.2" 57 | uritemplate = "4.1.1" 58 | urllib3 = "2.2.3" 59 | whitenoise = "6.8.2" 60 | 61 | 62 | [build-system] 63 | requires = ["poetry-core"] 64 | build-backend = "poetry.core.masonry.api" 65 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/requirements.txt -------------------------------------------------------------------------------- /schema.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: PYNIGERIA BACKEND API 4 | version: 1.0.0 5 | paths: 6 | /api/bookmark/: 7 | get: 8 | operationId: api_bookmark_list 9 | tags: 10 | - api 11 | security: 12 | - cookieAuth: [] 13 | - basicAuth: [] 14 | responses: 15 | '200': 16 | content: 17 | application/json: 18 | schema: 19 | type: array 20 | items: 21 | $ref: '#/components/schemas/Bookmark' 22 | description: '' 23 | post: 24 | operationId: api_bookmark_create 25 | tags: 26 | - api 27 | requestBody: 28 | content: 29 | application/json: 30 | schema: 31 | $ref: '#/components/schemas/CreateBookmark' 32 | application/x-www-form-urlencoded: 33 | schema: 34 | $ref: '#/components/schemas/CreateBookmark' 35 | multipart/form-data: 36 | schema: 37 | $ref: '#/components/schemas/CreateBookmark' 38 | required: true 39 | security: 40 | - cookieAuth: [] 41 | - basicAuth: [] 42 | responses: 43 | '201': 44 | content: 45 | application/json: 46 | schema: 47 | $ref: '#/components/schemas/CreateBookmark' 48 | description: '' 49 | /api/bookmark/{id}/: 50 | get: 51 | operationId: api_bookmark_retrieve 52 | parameters: 53 | - in: path 54 | name: id 55 | schema: 56 | type: integer 57 | description: A unique integer value identifying this bookmark. 58 | required: true 59 | tags: 60 | - api 61 | security: 62 | - cookieAuth: [] 63 | - basicAuth: [] 64 | responses: 65 | '200': 66 | content: 67 | application/json: 68 | schema: 69 | $ref: '#/components/schemas/Bookmark' 70 | description: '' 71 | put: 72 | operationId: api_bookmark_update 73 | parameters: 74 | - in: path 75 | name: id 76 | schema: 77 | type: integer 78 | description: A unique integer value identifying this bookmark. 79 | required: true 80 | tags: 81 | - api 82 | requestBody: 83 | content: 84 | application/json: 85 | schema: 86 | $ref: '#/components/schemas/Bookmark' 87 | application/x-www-form-urlencoded: 88 | schema: 89 | $ref: '#/components/schemas/Bookmark' 90 | multipart/form-data: 91 | schema: 92 | $ref: '#/components/schemas/Bookmark' 93 | security: 94 | - cookieAuth: [] 95 | - basicAuth: [] 96 | responses: 97 | '200': 98 | content: 99 | application/json: 100 | schema: 101 | $ref: '#/components/schemas/Bookmark' 102 | description: '' 103 | patch: 104 | operationId: api_bookmark_partial_update 105 | parameters: 106 | - in: path 107 | name: id 108 | schema: 109 | type: integer 110 | description: A unique integer value identifying this bookmark. 111 | required: true 112 | tags: 113 | - api 114 | requestBody: 115 | content: 116 | application/json: 117 | schema: 118 | $ref: '#/components/schemas/PatchedBookmark' 119 | application/x-www-form-urlencoded: 120 | schema: 121 | $ref: '#/components/schemas/PatchedBookmark' 122 | multipart/form-data: 123 | schema: 124 | $ref: '#/components/schemas/PatchedBookmark' 125 | security: 126 | - cookieAuth: [] 127 | - basicAuth: [] 128 | responses: 129 | '200': 130 | content: 131 | application/json: 132 | schema: 133 | $ref: '#/components/schemas/Bookmark' 134 | description: '' 135 | delete: 136 | operationId: api_bookmark_destroy 137 | parameters: 138 | - in: path 139 | name: id 140 | schema: 141 | type: integer 142 | description: A unique integer value identifying this bookmark. 143 | required: true 144 | tags: 145 | - api 146 | security: 147 | - cookieAuth: [] 148 | - basicAuth: [] 149 | responses: 150 | '204': 151 | description: No response body 152 | /api/job/: 153 | get: 154 | operationId: api_job_list 155 | parameters: 156 | - in: query 157 | name: company 158 | schema: 159 | type: string 160 | - in: query 161 | name: created_at_after 162 | schema: 163 | type: string 164 | format: date 165 | - in: query 166 | name: created_at_before 167 | schema: 168 | type: string 169 | format: date 170 | - in: query 171 | name: employment_type 172 | schema: 173 | type: string 174 | enum: 175 | - Contract 176 | - Full Time 177 | - Internship 178 | - Part Time 179 | - Voluntary 180 | description: |- 181 | * `Full Time` - Full Time 182 | * `Part Time` - Part Time 183 | * `Contract` - Contract 184 | * `Internship` - Internship 185 | * `Voluntary` - Voluntary 186 | - in: query 187 | name: location 188 | schema: 189 | type: string 190 | - name: ordering 191 | required: false 192 | in: query 193 | description: Which field to use when ordering the results. 194 | schema: 195 | type: string 196 | - in: query 197 | name: posted_by 198 | schema: 199 | type: string 200 | - in: query 201 | name: posted_by__id 202 | schema: 203 | type: string 204 | - in: query 205 | name: salary 206 | schema: 207 | type: number 208 | - name: search 209 | required: false 210 | in: query 211 | description: A search term. 212 | schema: 213 | type: string 214 | - in: query 215 | name: skills 216 | schema: 217 | type: string 218 | - in: query 219 | name: skills__name 220 | schema: 221 | type: string 222 | - in: query 223 | name: title 224 | schema: 225 | type: string 226 | tags: 227 | - api 228 | security: 229 | - cookieAuth: [] 230 | - jwtAuth: [] 231 | responses: 232 | '200': 233 | content: 234 | application/json: 235 | schema: 236 | type: array 237 | items: 238 | $ref: '#/components/schemas/Job' 239 | description: '' 240 | post: 241 | operationId: api_job_create 242 | tags: 243 | - api 244 | requestBody: 245 | content: 246 | application/json: 247 | schema: 248 | $ref: '#/components/schemas/Job' 249 | application/x-www-form-urlencoded: 250 | schema: 251 | $ref: '#/components/schemas/Job' 252 | multipart/form-data: 253 | schema: 254 | $ref: '#/components/schemas/Job' 255 | required: true 256 | security: 257 | - cookieAuth: [] 258 | - jwtAuth: [] 259 | responses: 260 | '201': 261 | content: 262 | application/json: 263 | schema: 264 | $ref: '#/components/schemas/Job' 265 | description: '' 266 | /api/job/{slug}/: 267 | get: 268 | operationId: api_job_retrieve 269 | parameters: 270 | - in: path 271 | name: slug 272 | schema: 273 | type: string 274 | format: uuid 275 | required: true 276 | tags: 277 | - api 278 | security: 279 | - cookieAuth: [] 280 | - jwtAuth: [] 281 | responses: 282 | '200': 283 | content: 284 | application/json: 285 | schema: 286 | $ref: '#/components/schemas/Job' 287 | description: '' 288 | put: 289 | operationId: api_job_update 290 | parameters: 291 | - in: path 292 | name: slug 293 | schema: 294 | type: string 295 | format: uuid 296 | required: true 297 | tags: 298 | - api 299 | requestBody: 300 | content: 301 | application/json: 302 | schema: 303 | $ref: '#/components/schemas/Job' 304 | application/x-www-form-urlencoded: 305 | schema: 306 | $ref: '#/components/schemas/Job' 307 | multipart/form-data: 308 | schema: 309 | $ref: '#/components/schemas/Job' 310 | required: true 311 | security: 312 | - cookieAuth: [] 313 | - jwtAuth: [] 314 | responses: 315 | '200': 316 | content: 317 | application/json: 318 | schema: 319 | $ref: '#/components/schemas/Job' 320 | description: '' 321 | patch: 322 | operationId: api_job_partial_update 323 | parameters: 324 | - in: path 325 | name: slug 326 | schema: 327 | type: string 328 | format: uuid 329 | required: true 330 | tags: 331 | - api 332 | requestBody: 333 | content: 334 | application/json: 335 | schema: 336 | $ref: '#/components/schemas/PatchedJob' 337 | application/x-www-form-urlencoded: 338 | schema: 339 | $ref: '#/components/schemas/PatchedJob' 340 | multipart/form-data: 341 | schema: 342 | $ref: '#/components/schemas/PatchedJob' 343 | security: 344 | - cookieAuth: [] 345 | - jwtAuth: [] 346 | responses: 347 | '200': 348 | content: 349 | application/json: 350 | schema: 351 | $ref: '#/components/schemas/Job' 352 | description: '' 353 | delete: 354 | operationId: api_job_destroy 355 | parameters: 356 | - in: path 357 | name: slug 358 | schema: 359 | type: string 360 | format: uuid 361 | required: true 362 | tags: 363 | - api 364 | security: 365 | - cookieAuth: [] 366 | - jwtAuth: [] 367 | responses: 368 | '204': 369 | description: No response body 370 | /api/job/job-list/: 371 | get: 372 | operationId: api_job_job_list_retrieve 373 | tags: 374 | - api 375 | security: 376 | - cookieAuth: [] 377 | - jwtAuth: [] 378 | responses: 379 | '200': 380 | content: 381 | application/json: 382 | schema: 383 | $ref: '#/components/schemas/Job' 384 | description: '' 385 | /api/v1/authentication/login/: 386 | post: 387 | operationId: v1_login 388 | tags: 389 | - auth_v1 390 | requestBody: 391 | content: 392 | application/json: 393 | schema: 394 | $ref: '#/components/schemas/Login' 395 | application/x-www-form-urlencoded: 396 | schema: 397 | $ref: '#/components/schemas/Login' 398 | multipart/form-data: 399 | schema: 400 | $ref: '#/components/schemas/Login' 401 | required: true 402 | security: 403 | - cookieAuth: [] 404 | - basicAuth: [] 405 | responses: 406 | '200': 407 | content: 408 | application/json: 409 | schema: 410 | $ref: '#/components/schemas/Login' 411 | description: '' 412 | /api/v1/authentication/register/: 413 | post: 414 | operationId: v1_register 415 | tags: 416 | - auth_v1 417 | requestBody: 418 | content: 419 | application/json: 420 | schema: 421 | $ref: '#/components/schemas/Register' 422 | application/x-www-form-urlencoded: 423 | schema: 424 | $ref: '#/components/schemas/Register' 425 | multipart/form-data: 426 | schema: 427 | $ref: '#/components/schemas/Register' 428 | required: true 429 | security: 430 | - cookieAuth: [] 431 | - basicAuth: [] 432 | responses: 433 | '200': 434 | content: 435 | application/json: 436 | schema: 437 | $ref: '#/components/schemas/Register' 438 | description: '' 439 | /api/v1/authentication/social/begin/{backend}/: 440 | get: 441 | operationId: v1_social_auth_begin 442 | description: This view initiates social oauth authentication 443 | parameters: 444 | - in: path 445 | name: backend 446 | schema: 447 | type: string 448 | required: true 449 | tags: 450 | - auth_v1 451 | security: 452 | - cookieAuth: [] 453 | - basicAuth: [] 454 | responses: 455 | '200': 456 | description: No response body 457 | /api/v1/authentication/social/complete/{backend}/: 458 | get: 459 | operationId: v1_social_auth_complete 460 | description: This view completes social oauth authentication 461 | parameters: 462 | - in: path 463 | name: backend 464 | schema: 465 | type: string 466 | required: true 467 | tags: 468 | - auth_v1 469 | security: 470 | - cookieAuth: [] 471 | - basicAuth: [] 472 | responses: 473 | '200': 474 | description: No response body 475 | /api/v1/authentication/totp-device/create/: 476 | post: 477 | operationId: v1_create_totp_device 478 | tags: 479 | - auth_v1 480 | requestBody: 481 | content: 482 | application/json: 483 | schema: 484 | $ref: '#/components/schemas/TOTPDeviceCreate' 485 | application/x-www-form-urlencoded: 486 | schema: 487 | $ref: '#/components/schemas/TOTPDeviceCreate' 488 | multipart/form-data: 489 | schema: 490 | $ref: '#/components/schemas/TOTPDeviceCreate' 491 | required: true 492 | security: 493 | - cookieAuth: [] 494 | - basicAuth: [] 495 | responses: 496 | '200': 497 | content: 498 | application/json: 499 | schema: 500 | $ref: '#/components/schemas/TOTPDeviceCreate' 501 | description: '' 502 | /api/v1/authentication/totp-device/qrcode/: 503 | post: 504 | operationId: v1_get_qrcode 505 | tags: 506 | - auth_v1 507 | requestBody: 508 | content: 509 | application/json: 510 | schema: 511 | $ref: '#/components/schemas/QRCodeData' 512 | application/x-www-form-urlencoded: 513 | schema: 514 | $ref: '#/components/schemas/QRCodeData' 515 | multipart/form-data: 516 | schema: 517 | $ref: '#/components/schemas/QRCodeData' 518 | required: true 519 | security: 520 | - cookieAuth: [] 521 | - basicAuth: [] 522 | responses: 523 | '200': 524 | content: 525 | application/json: 526 | schema: 527 | $ref: '#/components/schemas/QRCodeData' 528 | description: '' 529 | /api/v1/authentication/totp-device/verify/: 530 | post: 531 | operationId: v1_verify_totp_device 532 | tags: 533 | - auth_v1 534 | requestBody: 535 | content: 536 | application/json: 537 | schema: 538 | $ref: '#/components/schemas/VerifyTOTPDevice' 539 | application/x-www-form-urlencoded: 540 | schema: 541 | $ref: '#/components/schemas/VerifyTOTPDevice' 542 | multipart/form-data: 543 | schema: 544 | $ref: '#/components/schemas/VerifyTOTPDevice' 545 | required: true 546 | security: 547 | - cookieAuth: [] 548 | - basicAuth: [] 549 | responses: 550 | '200': 551 | content: 552 | application/json: 553 | schema: 554 | $ref: '#/components/schemas/VerifyTOTPDevice' 555 | description: '' 556 | /api/v1/authentication/verify-email/begin/: 557 | post: 558 | operationId: v1_verify_email_begin 559 | description: This view exists to initiate email verification manually if the 560 | auto option fails. 561 | tags: 562 | - auth_v1 563 | requestBody: 564 | content: 565 | application/json: 566 | schema: 567 | $ref: '#/components/schemas/EmailVerifyBegin' 568 | application/x-www-form-urlencoded: 569 | schema: 570 | $ref: '#/components/schemas/EmailVerifyBegin' 571 | multipart/form-data: 572 | schema: 573 | $ref: '#/components/schemas/EmailVerifyBegin' 574 | required: true 575 | security: 576 | - cookieAuth: [] 577 | - basicAuth: [] 578 | responses: 579 | '200': 580 | content: 581 | application/json: 582 | schema: 583 | $ref: '#/components/schemas/EmailVerifyBegin' 584 | description: '' 585 | /api/v1/authentication/verify-email/complete/{token}/: 586 | post: 587 | operationId: v1_verify_email_complete 588 | parameters: 589 | - in: path 590 | name: token 591 | schema: 592 | type: string 593 | required: true 594 | tags: 595 | - auth_v1 596 | requestBody: 597 | content: 598 | application/json: 599 | schema: 600 | $ref: '#/components/schemas/EmailVerifyComplete' 601 | application/x-www-form-urlencoded: 602 | schema: 603 | $ref: '#/components/schemas/EmailVerifyComplete' 604 | multipart/form-data: 605 | schema: 606 | $ref: '#/components/schemas/EmailVerifyComplete' 607 | security: 608 | - cookieAuth: [] 609 | - basicAuth: [] 610 | responses: 611 | '200': 612 | content: 613 | application/json: 614 | schema: 615 | $ref: '#/components/schemas/EmailVerifyComplete' 616 | description: '' 617 | /api/v1/knowledge-base/api/uploads/: 618 | get: 619 | operationId: api_v1_knowledge_base_api_uploads_list 620 | tags: 621 | - api 622 | security: 623 | - cookieAuth: [] 624 | - basicAuth: [] 625 | responses: 626 | '200': 627 | content: 628 | application/json: 629 | schema: 630 | type: array 631 | items: 632 | $ref: '#/components/schemas/UserUpload' 633 | description: '' 634 | post: 635 | operationId: api_v1_knowledge_base_api_uploads_create 636 | tags: 637 | - api 638 | requestBody: 639 | content: 640 | application/json: 641 | schema: 642 | $ref: '#/components/schemas/UserUpload' 643 | application/x-www-form-urlencoded: 644 | schema: 645 | $ref: '#/components/schemas/UserUpload' 646 | multipart/form-data: 647 | schema: 648 | $ref: '#/components/schemas/UserUpload' 649 | required: true 650 | security: 651 | - cookieAuth: [] 652 | - basicAuth: [] 653 | responses: 654 | '201': 655 | content: 656 | application/json: 657 | schema: 658 | $ref: '#/components/schemas/UserUpload' 659 | description: '' 660 | /api/v1/knowledge-base/api/uploads/{id}/: 661 | get: 662 | operationId: api_v1_knowledge_base_api_uploads_retrieve 663 | parameters: 664 | - in: path 665 | name: id 666 | schema: 667 | type: integer 668 | required: true 669 | tags: 670 | - api 671 | security: 672 | - cookieAuth: [] 673 | - basicAuth: [] 674 | responses: 675 | '200': 676 | content: 677 | application/json: 678 | schema: 679 | $ref: '#/components/schemas/UserUpload' 680 | description: '' 681 | put: 682 | operationId: api_v1_knowledge_base_api_uploads_update 683 | parameters: 684 | - in: path 685 | name: id 686 | schema: 687 | type: integer 688 | required: true 689 | tags: 690 | - api 691 | requestBody: 692 | content: 693 | application/json: 694 | schema: 695 | $ref: '#/components/schemas/UserUpload' 696 | application/x-www-form-urlencoded: 697 | schema: 698 | $ref: '#/components/schemas/UserUpload' 699 | multipart/form-data: 700 | schema: 701 | $ref: '#/components/schemas/UserUpload' 702 | required: true 703 | security: 704 | - cookieAuth: [] 705 | - basicAuth: [] 706 | responses: 707 | '200': 708 | content: 709 | application/json: 710 | schema: 711 | $ref: '#/components/schemas/UserUpload' 712 | description: '' 713 | patch: 714 | operationId: api_v1_knowledge_base_api_uploads_partial_update 715 | parameters: 716 | - in: path 717 | name: id 718 | schema: 719 | type: integer 720 | required: true 721 | tags: 722 | - api 723 | requestBody: 724 | content: 725 | application/json: 726 | schema: 727 | $ref: '#/components/schemas/PatchedUserUpload' 728 | application/x-www-form-urlencoded: 729 | schema: 730 | $ref: '#/components/schemas/PatchedUserUpload' 731 | multipart/form-data: 732 | schema: 733 | $ref: '#/components/schemas/PatchedUserUpload' 734 | security: 735 | - cookieAuth: [] 736 | - basicAuth: [] 737 | responses: 738 | '200': 739 | content: 740 | application/json: 741 | schema: 742 | $ref: '#/components/schemas/UserUpload' 743 | description: '' 744 | delete: 745 | operationId: api_v1_knowledge_base_api_uploads_destroy 746 | parameters: 747 | - in: path 748 | name: id 749 | schema: 750 | type: integer 751 | required: true 752 | tags: 753 | - api 754 | security: 755 | - cookieAuth: [] 756 | - basicAuth: [] 757 | responses: 758 | '204': 759 | description: No response body 760 | /api/v1/knowledge-base/api/uploads/{id}/status/: 761 | patch: 762 | operationId: api_v1_knowledge_base_api_uploads_status_partial_update 763 | parameters: 764 | - in: path 765 | name: id 766 | schema: 767 | type: integer 768 | required: true 769 | tags: 770 | - api 771 | security: 772 | - cookieAuth: [] 773 | - basicAuth: [] 774 | responses: 775 | '200': 776 | description: No response body 777 | /api/v1/knowledge-base/api/uploads/mine/: 778 | get: 779 | operationId: api_v1_knowledge_base_api_uploads_mine_list 780 | tags: 781 | - api 782 | security: 783 | - cookieAuth: [] 784 | - basicAuth: [] 785 | responses: 786 | '200': 787 | content: 788 | application/json: 789 | schema: 790 | type: array 791 | items: 792 | $ref: '#/components/schemas/UserUpload' 793 | description: '' 794 | /api/v1/knowledge-base/api/uploads/published/: 795 | get: 796 | operationId: api_v1_knowledge_base_api_uploads_published_list 797 | tags: 798 | - api 799 | security: 800 | - cookieAuth: [] 801 | - basicAuth: [] 802 | responses: 803 | '200': 804 | content: 805 | application/json: 806 | schema: 807 | type: array 808 | items: 809 | $ref: '#/components/schemas/UserUpload' 810 | description: '' 811 | /job/approve/{slug}/: 812 | post: 813 | operationId: job_approve_create 814 | parameters: 815 | - in: path 816 | name: slug 817 | schema: 818 | type: string 819 | required: true 820 | tags: 821 | - job 822 | requestBody: 823 | content: 824 | application/json: 825 | schema: 826 | $ref: '#/components/schemas/JobApprove' 827 | application/x-www-form-urlencoded: 828 | schema: 829 | $ref: '#/components/schemas/JobApprove' 830 | multipart/form-data: 831 | schema: 832 | $ref: '#/components/schemas/JobApprove' 833 | required: true 834 | security: 835 | - cookieAuth: [] 836 | - basicAuth: [] 837 | responses: 838 | '200': 839 | content: 840 | application/json: 841 | schema: 842 | $ref: '#/components/schemas/JobApprove' 843 | description: '' 844 | components: 845 | schemas: 846 | Bookmark: 847 | type: object 848 | properties: 849 | job_title: 850 | type: string 851 | readOnly: true 852 | job_company: 853 | type: string 854 | readOnly: true 855 | job_description: 856 | type: string 857 | readOnly: true 858 | job_instance: 859 | type: string 860 | format: uri 861 | readOnly: true 862 | notes: 863 | type: string 864 | nullable: true 865 | required: 866 | - job_company 867 | - job_description 868 | - job_instance 869 | - job_title 870 | CreateBookmark: 871 | type: object 872 | properties: 873 | job: 874 | type: integer 875 | notes: 876 | type: string 877 | nullable: true 878 | required: 879 | - job 880 | EmailVerifyBegin: 881 | type: object 882 | properties: 883 | email: 884 | type: string 885 | format: email 886 | writeOnly: true 887 | message: 888 | type: string 889 | readOnly: true 890 | required: 891 | - email 892 | - message 893 | EmailVerifyComplete: 894 | type: object 895 | properties: 896 | id: 897 | type: string 898 | readOnly: true 899 | email: 900 | type: string 901 | format: email 902 | readOnly: true 903 | is_email_verified: 904 | type: boolean 905 | readOnly: true 906 | message: 907 | type: string 908 | readOnly: true 909 | required: 910 | - email 911 | - id 912 | - is_email_verified 913 | - message 914 | EmploymentTypeEnum: 915 | enum: 916 | - Full Time 917 | - Part Time 918 | - Contract 919 | - Internship 920 | - Voluntary 921 | type: string 922 | description: |- 923 | * `Full Time` - Full Time 924 | * `Part Time` - Part Time 925 | * `Contract` - Contract 926 | * `Internship` - Internship 927 | * `Voluntary` - Voluntary 928 | Job: 929 | type: object 930 | properties: 931 | id: 932 | type: integer 933 | readOnly: true 934 | job: 935 | type: string 936 | format: uri 937 | readOnly: true 938 | skills: 939 | type: array 940 | items: 941 | $ref: '#/components/schemas/Skill' 942 | tags: 943 | type: array 944 | items: 945 | $ref: '#/components/schemas/Tag' 946 | employment_type: 947 | $ref: '#/components/schemas/EmploymentTypeEnum' 948 | company_name: 949 | type: string 950 | job_title: 951 | type: string 952 | maxLength: 255 953 | job_description: 954 | type: string 955 | status: 956 | allOf: 957 | - $ref: '#/components/schemas/JobStatusEnum' 958 | readOnly: true 959 | visibility: 960 | $ref: '#/components/schemas/VisibilityEnum' 961 | published_at: 962 | type: string 963 | format: date-time 964 | readOnly: true 965 | nullable: true 966 | application_deadline: 967 | type: string 968 | format: date-time 969 | nullable: true 970 | salary: 971 | type: string 972 | format: decimal 973 | pattern: ^-?\d{0,15}(?:\.\d{0,2})?$ 974 | nullable: true 975 | views_count: 976 | type: integer 977 | readOnly: true 978 | applications_count: 979 | type: integer 980 | readOnly: true 981 | version: 982 | type: integer 983 | readOnly: true 984 | created_at: 985 | type: string 986 | format: date-time 987 | readOnly: true 988 | is_approved: 989 | type: boolean 990 | readOnly: true 991 | company: 992 | type: string 993 | nullable: true 994 | posted_by: 995 | type: string 996 | readOnly: true 997 | original_job: 998 | type: integer 999 | readOnly: true 1000 | nullable: true 1001 | required: 1002 | - applications_count 1003 | - created_at 1004 | - employment_type 1005 | - id 1006 | - is_approved 1007 | - job 1008 | - job_description 1009 | - job_title 1010 | - original_job 1011 | - posted_by 1012 | - published_at 1013 | - skills 1014 | - status 1015 | - tags 1016 | - version 1017 | - views_count 1018 | JobApprove: 1019 | type: object 1020 | properties: 1021 | is_approved: 1022 | type: boolean 1023 | message: 1024 | type: string 1025 | required: 1026 | - is_approved 1027 | JobStatusEnum: 1028 | enum: 1029 | - Draft 1030 | - Published 1031 | - Archived 1032 | - Expired 1033 | type: string 1034 | description: |- 1035 | * `Draft` - Draft 1036 | * `Published` - Published 1037 | * `Archived` - Archived 1038 | * `Expired` - Expired 1039 | Login: 1040 | type: object 1041 | properties: 1042 | email: 1043 | type: string 1044 | format: email 1045 | otp_code: 1046 | type: string 1047 | writeOnly: true 1048 | id: 1049 | type: string 1050 | readOnly: true 1051 | access: 1052 | type: string 1053 | readOnly: true 1054 | refresh: 1055 | type: string 1056 | readOnly: true 1057 | required: 1058 | - access 1059 | - email 1060 | - id 1061 | - otp_code 1062 | - refresh 1063 | PatchedBookmark: 1064 | type: object 1065 | properties: 1066 | job_title: 1067 | type: string 1068 | readOnly: true 1069 | job_company: 1070 | type: string 1071 | readOnly: true 1072 | job_description: 1073 | type: string 1074 | readOnly: true 1075 | job_instance: 1076 | type: string 1077 | format: uri 1078 | readOnly: true 1079 | notes: 1080 | type: string 1081 | nullable: true 1082 | PatchedJob: 1083 | type: object 1084 | properties: 1085 | id: 1086 | type: integer 1087 | readOnly: true 1088 | job: 1089 | type: string 1090 | format: uri 1091 | readOnly: true 1092 | skills: 1093 | type: array 1094 | items: 1095 | $ref: '#/components/schemas/Skill' 1096 | tags: 1097 | type: array 1098 | items: 1099 | $ref: '#/components/schemas/Tag' 1100 | employment_type: 1101 | $ref: '#/components/schemas/EmploymentTypeEnum' 1102 | company_name: 1103 | type: string 1104 | job_title: 1105 | type: string 1106 | maxLength: 255 1107 | job_description: 1108 | type: string 1109 | status: 1110 | allOf: 1111 | - $ref: '#/components/schemas/JobStatusEnum' 1112 | readOnly: true 1113 | visibility: 1114 | $ref: '#/components/schemas/VisibilityEnum' 1115 | published_at: 1116 | type: string 1117 | format: date-time 1118 | readOnly: true 1119 | nullable: true 1120 | application_deadline: 1121 | type: string 1122 | format: date-time 1123 | nullable: true 1124 | salary: 1125 | type: string 1126 | format: decimal 1127 | pattern: ^-?\d{0,15}(?:\.\d{0,2})?$ 1128 | nullable: true 1129 | views_count: 1130 | type: integer 1131 | readOnly: true 1132 | applications_count: 1133 | type: integer 1134 | readOnly: true 1135 | version: 1136 | type: integer 1137 | readOnly: true 1138 | created_at: 1139 | type: string 1140 | format: date-time 1141 | readOnly: true 1142 | is_approved: 1143 | type: boolean 1144 | readOnly: true 1145 | company: 1146 | type: string 1147 | nullable: true 1148 | posted_by: 1149 | type: string 1150 | readOnly: true 1151 | original_job: 1152 | type: integer 1153 | readOnly: true 1154 | nullable: true 1155 | PatchedUserUpload: 1156 | type: object 1157 | properties: 1158 | id: 1159 | type: integer 1160 | readOnly: true 1161 | upload_type: 1162 | $ref: '#/components/schemas/UploadTypeEnum' 1163 | file: 1164 | type: string 1165 | format: uri 1166 | description: 1167 | type: string 1168 | nullable: true 1169 | created_at: 1170 | type: string 1171 | format: date-time 1172 | readOnly: true 1173 | published_at: 1174 | type: string 1175 | format: date-time 1176 | readOnly: true 1177 | status: 1178 | allOf: 1179 | - $ref: '#/components/schemas/UserUploadStatusEnum' 1180 | readOnly: true 1181 | tags: 1182 | type: string 1183 | readOnly: true 1184 | QRCodeData: 1185 | type: object 1186 | properties: 1187 | otpauth_url: 1188 | type: string 1189 | readOnly: true 1190 | email: 1191 | type: string 1192 | format: email 1193 | required: 1194 | - email 1195 | - otpauth_url 1196 | Register: 1197 | type: object 1198 | properties: 1199 | id: 1200 | type: string 1201 | readOnly: true 1202 | email: 1203 | type: string 1204 | format: email 1205 | is_email_verified: 1206 | type: boolean 1207 | readOnly: true 1208 | message: 1209 | type: string 1210 | readOnly: true 1211 | required: 1212 | - email 1213 | - id 1214 | - is_email_verified 1215 | - message 1216 | Skill: 1217 | type: object 1218 | properties: 1219 | id: 1220 | type: integer 1221 | readOnly: true 1222 | name: 1223 | type: string 1224 | required: 1225 | - id 1226 | - name 1227 | TOTPDeviceCreate: 1228 | type: object 1229 | properties: 1230 | user: 1231 | type: string 1232 | readOnly: true 1233 | name: 1234 | type: string 1235 | readOnly: true 1236 | email: 1237 | type: string 1238 | format: email 1239 | confirmed: 1240 | type: boolean 1241 | readOnly: true 1242 | default: false 1243 | required: 1244 | - confirmed 1245 | - email 1246 | - name 1247 | - user 1248 | Tag: 1249 | type: object 1250 | properties: 1251 | id: 1252 | type: integer 1253 | readOnly: true 1254 | name: 1255 | type: string 1256 | required: 1257 | - id 1258 | - name 1259 | UploadTypeEnum: 1260 | enum: 1261 | - PDF 1262 | - EBOOK 1263 | - IMAGE 1264 | type: string 1265 | description: |- 1266 | * `PDF` - PDF Document 1267 | * `EBOOK` - Ebook 1268 | * `IMAGE` - image 1269 | UserUpload: 1270 | type: object 1271 | properties: 1272 | id: 1273 | type: integer 1274 | readOnly: true 1275 | upload_type: 1276 | $ref: '#/components/schemas/UploadTypeEnum' 1277 | file: 1278 | type: string 1279 | format: uri 1280 | description: 1281 | type: string 1282 | nullable: true 1283 | created_at: 1284 | type: string 1285 | format: date-time 1286 | readOnly: true 1287 | published_at: 1288 | type: string 1289 | format: date-time 1290 | readOnly: true 1291 | status: 1292 | allOf: 1293 | - $ref: '#/components/schemas/UserUploadStatusEnum' 1294 | readOnly: true 1295 | tags: 1296 | type: string 1297 | readOnly: true 1298 | required: 1299 | - created_at 1300 | - file 1301 | - id 1302 | - published_at 1303 | - status 1304 | - tags 1305 | - upload_type 1306 | UserUploadStatusEnum: 1307 | enum: 1308 | - PENDING 1309 | - APPROVED 1310 | - REJECTED 1311 | type: string 1312 | description: |- 1313 | * `PENDING` - Pending 1314 | * `APPROVED` - Approved 1315 | * `REJECTED` - Rejected 1316 | VerifyTOTPDevice: 1317 | type: object 1318 | properties: 1319 | email: 1320 | type: string 1321 | format: email 1322 | otp_token: 1323 | type: string 1324 | writeOnly: true 1325 | user: 1326 | type: string 1327 | readOnly: true 1328 | name: 1329 | type: string 1330 | readOnly: true 1331 | confirmed: 1332 | type: boolean 1333 | readOnly: true 1334 | message: 1335 | type: string 1336 | readOnly: true 1337 | required: 1338 | - confirmed 1339 | - email 1340 | - message 1341 | - name 1342 | - otp_token 1343 | - user 1344 | VisibilityEnum: 1345 | enum: 1346 | - Private 1347 | - Internal 1348 | - Public 1349 | - Featured 1350 | type: string 1351 | description: |- 1352 | * `Private` - Private 1353 | * `Internal` - Internal 1354 | * `Public` - Public 1355 | * `Featured` - Featured 1356 | securitySchemes: 1357 | basicAuth: 1358 | type: http 1359 | scheme: basic 1360 | cookieAuth: 1361 | type: apiKey 1362 | in: cookie 1363 | name: sessionid 1364 | jwtAuth: 1365 | type: http 1366 | scheme: bearer 1367 | bearerFormat: JWT 1368 | -------------------------------------------------------------------------------- /templates/email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ email_title }} 7 | 63 | 64 | 65 |
66 |

Hello, {{ user_name }}!

67 |

{{ email_message }}

68 | 69 | {% if additional_message %} 70 |

{{additional_message }}

71 | {%endif%} 72 | 73 | {% if job_link %} 74 | View Job 75 | {% endif %} 76 | 80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | Fix the issue of company when a company is not created 2 | setup a notification system to notify admin when a job has been posted 3 | Dont forget t handle posting date and choices 4 | will have to change the url setting for the mail 5 | tracking and metric 6 | 7 | handle object operation when user is not owner of object or is not staff -------------------------------------------------------------------------------- /tracking/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-Nigeria/pynigeria-backend/34c26a7c8de06ad5bf42fe8448fe234ef4cbc398/tracking/__init__.py -------------------------------------------------------------------------------- /tracking/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /tracking/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TrackingConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tracking" 7 | -------------------------------------------------------------------------------- /tracking/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.conf import settings 4 | # from django.contrib.auth.models import User 5 | # Create your models here. 6 | 7 | 8 | class UserActivity(models.Model): 9 | user = models.OneToOneField(settings.AUTH_USER_MODEL,related_name='activity', on_delete=models.CASCADE,null=True,blank=True) 10 | whatsapp_number = models.IntegerField(null=True,blank=True) 11 | status = models.BooleanField(default=True) 12 | 13 | def __init__(self): 14 | return f"{self.user} Activity" 15 | 16 | 17 | class Message(models.Model): 18 | user = models.ForeignKey( 19 | UserActivity, on_delete=models.CASCADE, related_name="messages" 20 | ) 21 | title = models.CharField(max_length=10000, default="This Month") 22 | total_message = models.IntegerField(default=0) 23 | 24 | def __init__(self): 25 | return f"{self.user.user} Messages" 26 | -------------------------------------------------------------------------------- /tracking/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /tracking/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | --------------------------------------------------------------------------------