Merge remote-tracking branch 'origin/main' into nb-locale
Updating nb-locale branch
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* @seanmorley15
|
46
.github/workflows/cdn-beta.yml
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
name: Upload beta CDN image to GHCR and Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths:
|
||||
- "cdn/**"
|
||||
|
||||
env:
|
||||
IMAGE_NAME: "adventurelog-cdn"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: set lower case owner name
|
||||
run: |
|
||||
echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: "${{ github.repository_owner }}"
|
||||
|
||||
- name: Build Docker images
|
||||
run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:beta -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:beta ./cdn
|
46
.github/workflows/cdn-latest.yml
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
name: Upload latest CDN image to GHCR and Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "cdn/**"
|
||||
|
||||
env:
|
||||
IMAGE_NAME: "adventurelog-cdn"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: set lower case owner name
|
||||
run: |
|
||||
echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: "${{ github.repository_owner }}"
|
||||
|
||||
- name: Build Docker images
|
||||
run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:latest -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:latest ./cdn
|
43
.github/workflows/cdn-release.yml
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
name: Upload the tagged release CDN image to GHCR and Docker Hub
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
env:
|
||||
IMAGE_NAME: "adventurelog-cdn"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: set lower case owner name
|
||||
run: |
|
||||
echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: "${{ github.repository_owner }}"
|
||||
|
||||
- name: Build Docker images
|
||||
run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:${{ github.event.release.tag_name }} -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:${{ github.event.release.tag_name }} ./cdn
|
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
# Ignore everything in the .venv folder
|
||||
.venv/
|
||||
.vscode/settings.json
|
||||
.pnpm-store/
|
6
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"lokalise.i18n-ally",
|
||||
"svelte.svelte-vscode"
|
||||
]
|
||||
}
|
|
@ -1,91 +1,50 @@
|
|||
# Contributing to AdventureLog
|
||||
|
||||
When contributing to this repository, please first discuss the change you wish to make via issue,
|
||||
email, or any other method with the owners of this repository before making a change.
|
||||
|
||||
Please note we have a code of conduct, please follow it in all your interactions with the project.
|
||||
We’re excited to have you contribute to AdventureLog! To ensure that this community remains welcoming and productive for all users and developers, please follow this simple Code of Conduct.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Please make sure you create an issue first for your change so you can link any pull requests to this issue. There should be a clear relationship between pull requests and issues.
|
||||
2. Update the README.md with details of changes to the interface, this includes new environment
|
||||
variables, exposed ports, useful file locations and container parameters.
|
||||
3. Increase the version numbers in any examples files and the README.md to the new version that this
|
||||
Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
|
||||
4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you
|
||||
do not have permission to do that, you may request the second reviewer to merge it for you.
|
||||
1. **Open an Issue First**: Discuss any changes or features you plan to implement by opening an issue. This helps to clarify your idea and ensures there’s a shared understanding.
|
||||
2. **Document Changes**: If your changes impact the user interface, add new environment variables, or introduce new container configurations, make sure to update the documentation accordingly. The documentation is located in the `documentation` folder.
|
||||
3. **Pull Request**: Submit a pull request with your changes. Make sure to reference the issue you opened in the description.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
### Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
At AdventureLog, we are committed to creating a community that fosters adventure, exploration, and innovation. We encourage diverse participation and strive to maintain a space where everyone feels welcome to contribute, regardless of their background or experience level. We ask that you contribute with respect and kindness, making sure to prioritize collaboration and mutual growth.
|
||||
|
||||
### Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
In order to maintain a positive environment, we encourage the following behaviors:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
- **Inclusivity**: Use welcoming and inclusive language that fosters collaboration across all perspectives and experiences.
|
||||
- **Respect**: Respect differing opinions and engage with empathy, understanding that each person’s perspective is valuable.
|
||||
- **Constructive Feedback**: Offer feedback that helps improve the project and allows contributors to grow from it.
|
||||
- **Adventure Spirit**: Bring the same sense of curiosity, discovery, and positivity that drives AdventureLog into all interactions with the community.
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- Personal attacks, trolling, or any form of harassment.
|
||||
- Insensitive or discriminatory language, including sexualized comments or imagery.
|
||||
- Spamming or misusing project spaces for personal gain.
|
||||
- Publishing or using others’ private information without permission.
|
||||
- Anything else that could be seen as disrespectful or unprofessional in a collaborative environment.
|
||||
|
||||
### Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
As maintainers of AdventureLog, we are committed to enforcing this Code of Conduct and taking corrective action when necessary. This may involve moderating comments, pulling code, or banning users who engage in harmful behaviors.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
We strive to foster a community that balances open collaboration with respect for all contributors.
|
||||
|
||||
### Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
This Code of Conduct applies in all spaces related to AdventureLog. This includes our GitHub repository, discussions, documentation, social media accounts, and events—both online and in person.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
If you experience or witness unacceptable behavior, please report it to the project team at `contact@adventurelog.app`. All reports will be confidential and handled swiftly. The maintainers will investigate the issue and take appropriate action as needed.
|
||||
|
||||
### Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
This Code of Conduct is inspired by the [Contributor Covenant](http://contributor-covenant.org), version 1.4, and adapted to fit the unique spirit of AdventureLog.
|
||||
|
|
205
README.md
|
@ -1,133 +1,150 @@
|
|||
# AdventureLog: Embark, Explore, Remember. 🌍
|
||||
<div align="center">
|
||||
|
||||
### _"Never forget an adventure with AdventureLog - Your ultimate travel companion!"_
|
||||
<img src="brand/adventurelog.png" alt="logo" width="200" height="auto" />
|
||||
<h1>AdventureLog</h1>
|
||||
|
||||
<p>
|
||||
The ultimate travel companion for the modern-day explorer.
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://demo.adventurelog.app">View Demo</a>
|
||||
<span> · </span>
|
||||
<a href="https://adventurelog.app">Documentation</a>
|
||||
<span> · </span>
|
||||
<a href="https://discord.gg/wRbQ9Egr8C">Discord</a>
|
||||
<span> · </span>
|
||||
<a href="https://buymeacoffee.com/seanmorley15">Support 💖</a>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
[](https://www.buymeacoffee.com/seanmorley15)
|
||||
<br />
|
||||
|
||||
- **[Documentation](https://adventurelog.app)**
|
||||
- **[Demo](https://demo.adventurelog.app)**
|
||||
- **[Join the AdventureLog Community Discord Server](https://discord.gg/wRbQ9Egr8C)**
|
||||
<!-- Table of Contents -->
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Docker 🐋](#docker-)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Configuration](#configuration)
|
||||
- [Frontend Container (web)](#frontend-container-web)
|
||||
- [Backend Container (server)](#backend-container-server)
|
||||
- [Proxy Container (nginx) Configuration](#proxy-container-nginx-configuration)
|
||||
- [Running the Containers](#running-the-containers)
|
||||
- [Screenshots 🖼️](#screenshots)
|
||||
- [About AdventureLog](#about-adventurelog)
|
||||
- [Attribution](#attribution)
|
||||
- [About the Project](#-about-the-project)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Tech Stack](#-tech-stack)
|
||||
- [Features](#-features)
|
||||
- [Roadmap](#-roadmap)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Contact](#-contact)
|
||||
- [Acknowledgements](#-acknowledgements)
|
||||
|
||||
# Installation
|
||||
<!-- About the Project -->
|
||||
|
||||
# Docker 🐋
|
||||
## ⭐ About the Project
|
||||
|
||||
Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers.
|
||||
**Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems.
|
||||
Starting from a simple idea of tracking travel locations (called adventures), AdventureLog has grown into a full-fledged travel companion. With AdventureLog, you can log your adventures, keep track of where you've been on the world map, plan your next trip collaboratively, and share your experiences with friends and family.
|
||||
|
||||
## Prerequisites
|
||||
AdventureLog was created to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone.
|
||||
|
||||
- Docker installed on your machine/server. You can learn how to download it [here](https://docs.docker.com/engine/install/).
|
||||
<!-- Screenshots -->
|
||||
|
||||
## Getting Started
|
||||
### 📷 Screenshots
|
||||
|
||||
Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine:
|
||||
<div align="center">
|
||||
<img src="./brand/screenshots/adventures.png" alt="Adventures" />
|
||||
<p>Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.</p>
|
||||
<img src="./brand/screenshots/details.png" alt="Adventure Details" />
|
||||
<p>Shows specific details about an adventure, including the name, date, location, description, and rating.</p>
|
||||
<img src="./brand/screenshots/edit.png" alt="Edit Modal" />
|
||||
<img src="./brand/screenshots/map.png" alt="Adventure Details" />
|
||||
<p>View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map</p>
|
||||
<img src="./brand/screenshots/dashboard.png" alt="Dashboard" />
|
||||
<p>Displays a summary of your adventures, including your world travel stats.</p>
|
||||
<img src="./brand/screenshots/itinerary.png" alt="Itinerary" />
|
||||
<p>Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.</p>
|
||||
<img src="./brand/screenshots/countries.png" alt="Countries" />
|
||||
<p>Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.</p>
|
||||
<img src="./brand/screenshots/regions.png" alt="Regions" />
|
||||
<p>Displays the regions for a specific country, includes a map view to visually select regions.</p>
|
||||
</div>
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
|
||||
```
|
||||
<!-- TechStack -->
|
||||
|
||||
## Configuration
|
||||
### 🚀 Tech Stack
|
||||
|
||||
Here is a summary of the configuration options available in the `docker-compose.yml` file:
|
||||
<details>
|
||||
<summary>Client</summary>
|
||||
<ul>
|
||||
<li><a href="https://svelte.dev/">SvelteKit</a></li>
|
||||
<li><a href="https://tailwindcss.com/">TailwindCSS</a></li>
|
||||
<li><a href="https://daisyui.com/">DaisyUI</a></li>
|
||||
<li><a href="https://github.com/dimfeld/svelte-maplibre/">Svelte MapLibre</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- make a table with colum name, is required, other -->
|
||||
<details>
|
||||
<summary>Server</summary>
|
||||
<ul>
|
||||
<li><a href="https://www.djangoproject.com/">Django</a></li>
|
||||
<li><a href="https://postgis.net/">PostGIS</a></li>
|
||||
<li><a href="https://www.django-rest-framework.org/">Django REST Framework</a></li>
|
||||
<li><a href="https://allauth.org/">AllAuth</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
<!-- Features -->
|
||||
|
||||
### Frontend Container (web)
|
||||
### 🎯 Features
|
||||
|
||||
| Name | Required | Description | Default Value |
|
||||
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. | http://server:8000 |
|
||||
| `ORIGIN` | Sometimes | Not needed if using HTTPS. If not, set it to the domain of what you will acess the app from. | http://localhost:8015 |
|
||||
| `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **kiliobytes**. | Infinity |
|
||||
- **Track Your Adventures** 🌍: Log your adventures and keep track of where you've been on the world map.
|
||||
- Adventures can store a variety of information, including the location, date, and description.
|
||||
- Adventures can be sorted into custom categories for easy organization.
|
||||
- Adventures can be marked as private or public, allowing you to share your adventures with friends and family.
|
||||
- Keep track of the countries and regions you've visited with the world travel book.
|
||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||
- Itineraries can be shared with friends and family for collaborative planning.
|
||||
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
||||
- Adventures and itineraries can be shared via a public link or directly with other AdventureLog users.
|
||||
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
|
||||
|
||||
### Backend Container (server)
|
||||
<!-- Roadmap -->
|
||||
|
||||
| Name | Required | Description | Default Value |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| `PGHOST` | Yes | Databse host. | db |
|
||||
| `PGDATABASE` | Yes | Database. | database |
|
||||
| `PGUSER` | Yes | Database user. | adventure |
|
||||
| `PGPASSWORD` | Yes | Database password. | changeme123 |
|
||||
| `DJANGO_ADMIN_USERNAME` | Yes | Default username. | admin |
|
||||
| `DJANGO_ADMIN_PASSWORD` | Yes | Default password, change after inital login. | admin |
|
||||
| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | admin@example.com |
|
||||
| `PUBLIC_URL` | Yes | This needs to match the outward port of the server and be accessible from where the app is used. It is used for the creation of image urls. | http://localhost:8016 |
|
||||
| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the orgins where you use your backend server and frontend. These values are comma seperated. | http://localhost:8016 |
|
||||
| `FRONTEND_URL` | Yes | This is the publicly accessible url to the **frontend** container. This link should be accessible for all users. Used for email generation. | http://localhost:8015 |
|
||||
## 🧭 Roadmap
|
||||
|
||||
## Running the Containers
|
||||
The AdventureLog Roadmap can be found [here](https://github.com/users/seanmorley15/projects/5)
|
||||
|
||||
To start the containers, run the following command:
|
||||
<!-- Contributing -->
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
## 👋 Contributing
|
||||
|
||||
Enjoy AdventureLog! 🎉
|
||||
<a href="https://github.com/seanmorley15/AdventureLog/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=seanmorley15/AdventureLog" />
|
||||
</a>
|
||||
|
||||
# Screenshots
|
||||
Contributions are always welcome!
|
||||
|
||||

|
||||
Displaying the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
|
||||
See `contributing.md` for ways to get started.
|
||||
|
||||

|
||||
Shows specific details about an adventure, including the name, date, location, description, and rating.
|
||||
<!-- License -->
|
||||
|
||||

|
||||
## 📃 License
|
||||
|
||||

|
||||
View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map.
|
||||
Distributed under the GNU General Public License v3.0. See `LICENSE` for more information.
|
||||
|
||||

|
||||
<!-- Contact -->
|
||||
|
||||

|
||||
## 🤝 Contact
|
||||
|
||||

|
||||
Sean Morley - [website](https://seanmorley.com)
|
||||
|
||||
# About AdventureLog
|
||||
Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software developer with a passion for travel and adventure. I created AdventureLog to help people like me document their adventures and plan new ones effortlessly. As a student, I am always looking for more opportunities to learn and grow, so feel free to reach out via the contact on my website if you would like to collaborate or chat!
|
||||
|
||||
AdventureLog is a Svelte Kit and Django application that utilizes a PostgreSQL database. Users can log the adventures they have experienced, as well as plan future ones. Key features include:
|
||||
<!-- Acknowledgments -->
|
||||
|
||||
- Logging past adventures with fields like name, date, location, description, and rating.
|
||||
- Planning future adventures with similar fields.
|
||||
- Tagging different activity types for better organization.
|
||||
- Viewing countries, regions, and marking visited regions.
|
||||
|
||||
AdventureLog aims to be your ultimate travel companion, helping you document your adventures and plan new ones effortlessly.
|
||||
|
||||
AdventureLog is licensed under the GNU General Public License v3.0.
|
||||
|
||||
<!-- ## Screenshots 🖼️
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Roadmap 🛣️
|
||||
|
||||
- Improved mobile device support
|
||||
- Password reset functionality
|
||||
- Improved error handling
|
||||
- Handling of adventure cards with variable width -->
|
||||
|
||||
# Attribution
|
||||
## 💎 Acknowledgements
|
||||
|
||||
- Logo Design by [nordtechtiger](https://github.com/nordtechtiger)
|
||||
- WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database)
|
||||
|
||||
### Top Supporters 💖
|
||||
|
||||
- [Veymax](https://x.com/veymax)
|
||||
- [nebriv](https://github.com/nebriv)
|
||||
- [Victor Butler](https://x.com/victor_butler)
|
||||
|
|
|
@ -20,23 +20,47 @@ done
|
|||
python manage.py migrate
|
||||
|
||||
# Create superuser if environment variables are set and there are no users present at all.
|
||||
if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ]; then
|
||||
if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ] && [ -n "$DJANGO_ADMIN_EMAIL" ]; then
|
||||
echo "Creating superuser..."
|
||||
python manage.py shell << EOF
|
||||
from django.contrib.auth import get_user_model
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
User = get_user_model()
|
||||
if User.objects.count() == 0:
|
||||
User.objects.create_superuser('$DJANGO_ADMIN_USERNAME', '$DJANGO_ADMIN_EMAIL', '$DJANGO_ADMIN_PASSWORD')
|
||||
|
||||
# Check if the user already exists
|
||||
if not User.objects.filter(username='$DJANGO_ADMIN_USERNAME').exists():
|
||||
# Create the superuser
|
||||
superuser = User.objects.create_superuser(
|
||||
username='$DJANGO_ADMIN_USERNAME',
|
||||
email='$DJANGO_ADMIN_EMAIL',
|
||||
password='$DJANGO_ADMIN_PASSWORD'
|
||||
)
|
||||
print("Superuser created successfully.")
|
||||
|
||||
# Create the EmailAddress object for AllAuth
|
||||
EmailAddress.objects.create(
|
||||
user=superuser,
|
||||
email='$DJANGO_ADMIN_EMAIL',
|
||||
verified=True,
|
||||
primary=True
|
||||
)
|
||||
print("EmailAddress object created successfully for AllAuth.")
|
||||
else:
|
||||
print("Superuser already exists.")
|
||||
EOF
|
||||
fi
|
||||
|
||||
|
||||
# Sync the countries and world travel regions
|
||||
# Sync the countries and world travel regions
|
||||
python manage.py download-countries
|
||||
if [ $? -eq 137 ]; then
|
||||
>&2 echo "WARNING: The download-countries command was interrupted. This is likely due to lack of memory allocated to the container or the host. Please try again with more memory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat /code/adventurelog.txt
|
||||
|
||||
# Start gunicorn
|
||||
gunicorn main.wsgi:application --bind 0.0.0.0:8000 --timeout 120 --workers 2
|
||||
gunicorn main.wsgi:application --bind [::]:8000 --timeout 120 --workers 2
|
|
@ -1,4 +1,5 @@
|
|||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
@ -12,29 +13,33 @@ http {
|
|||
|
||||
client_max_body_size 100M;
|
||||
|
||||
# The backend is running in the same container, so reference localhost
|
||||
upstream django {
|
||||
server server:8000; # Use the internal Docker networking
|
||||
server 127.0.0.1:8000; # Use localhost to point to Gunicorn running internally
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80; # NGINX always listens on port 80 inside the container
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
proxy_pass http://server:8000; # Explicitly forward to Django service
|
||||
proxy_pass http://django; # Forward to the upstream block
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
|
||||
location /static/ {
|
||||
alias /code/staticfiles/; # Serve static files directly
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /code/media/; # Serve media files directly
|
||||
# Serve protected media files with X-Accel-Redirect
|
||||
location /protectedMedia/ {
|
||||
internal; # Only internal requests are allowed
|
||||
alias /code/media/; # This should match Django MEDIA_ROOT
|
||||
try_files $uri =404; # Return a 404 if the file doesn't exist
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,4 +20,15 @@ EMAIL_BACKEND='console'
|
|||
# EMAIL_USE_SSL=True
|
||||
# EMAIL_HOST_USER='user'
|
||||
# EMAIL_HOST_PASSWORD='password'
|
||||
# DEFAULT_FROM_EMAIL='user@example.com'
|
||||
# DEFAULT_FROM_EMAIL='user@example.com'
|
||||
|
||||
|
||||
# ------------------- #
|
||||
# For Developers to start a Demo Database
|
||||
# docker run --name adventurelog-development -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin -e POSTGRES_DB=adventurelog -p 5432:5432 -d postgis/postgis:15-3.3
|
||||
|
||||
# PGHOST='localhost'
|
||||
# PGDATABASE='adventurelog'
|
||||
# PGUSER='admin'
|
||||
# PGPASSWORD='admin'
|
||||
# ------------------- #
|
0
backend/server/achievements/__init__.py
Normal file
9
backend/server/achievements/admin.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.contrib import admin
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
from achievements.models import Achievement, UserAchievement
|
||||
|
||||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
admin.site.register(Achievement)
|
||||
admin.site.register(UserAchievement)
|
6
backend/server/achievements/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AchievementsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'achievements'
|
0
backend/server/achievements/management/__init__.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
import json
|
||||
from django.core.management.base import BaseCommand
|
||||
from achievements.models import Achievement
|
||||
|
||||
US_STATE_CODES = [
|
||||
'US-AL', 'US-AK', 'US-AZ', 'US-AR', 'US-CA', 'US-CO', 'US-CT', 'US-DE',
|
||||
'US-FL', 'US-GA', 'US-HI', 'US-ID', 'US-IL', 'US-IN', 'US-IA', 'US-KS',
|
||||
'US-KY', 'US-LA', 'US-ME', 'US-MD', 'US-MA', 'US-MI', 'US-MN', 'US-MS',
|
||||
'US-MO', 'US-MT', 'US-NE', 'US-NV', 'US-NH', 'US-NJ', 'US-NM', 'US-NY',
|
||||
'US-NC', 'US-ND', 'US-OH', 'US-OK', 'US-OR', 'US-PA', 'US-RI', 'US-SC',
|
||||
'US-SD', 'US-TN', 'US-TX', 'US-UT', 'US-VT', 'US-VA', 'US-WA', 'US-WV',
|
||||
'US-WI', 'US-WY'
|
||||
]
|
||||
|
||||
ACHIEVEMENTS = [
|
||||
{
|
||||
"name": "First Adventure",
|
||||
"key": "achievements.first_adventure",
|
||||
"type": "adventure_count",
|
||||
"description": "Log your first adventure!",
|
||||
"condition": {"type": "adventure_count", "value": 1},
|
||||
},
|
||||
{
|
||||
"name": "Explorer",
|
||||
"key": "achievements.explorer",
|
||||
"type": "adventure_count",
|
||||
"description": "Log 10 adventures.",
|
||||
"condition": {"type": "adventure_count", "value": 10},
|
||||
},
|
||||
{
|
||||
"name": "Globetrotter",
|
||||
"key": "achievements.globetrotter",
|
||||
"type": "country_count",
|
||||
"description": "Visit 5 different countries.",
|
||||
"condition": {"type": "country_count", "value": 5},
|
||||
},
|
||||
{
|
||||
"name": "American Dream",
|
||||
"key": "achievements.american_dream",
|
||||
"type": "country_count",
|
||||
"description": "Visit all 50 states in the USA.",
|
||||
"condition": {"type": "country_count", "items": US_STATE_CODES},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds the database with predefined achievements"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
for achievement_data in ACHIEVEMENTS:
|
||||
achievement, created = Achievement.objects.update_or_create(
|
||||
name=achievement_data["name"],
|
||||
defaults={
|
||||
"description": achievement_data["description"],
|
||||
"condition": json.dumps(achievement_data["condition"]),
|
||||
"type": achievement_data["type"],
|
||||
"key": achievement_data["key"],
|
||||
},
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ Created: {achievement.name}"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"🔄 Updated: {achievement.name}"))
|
34
backend/server/achievements/models.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
import uuid
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
VALID_ACHIEVEMENT_TYPES = [
|
||||
"adventure_count",
|
||||
"country_count",
|
||||
]
|
||||
|
||||
class Achievement(models.Model):
|
||||
"""Stores all possible achievements"""
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
key = models.CharField(max_length=255, unique=True, default='achievements.other') # Used for frontend lookups, e.g. "achievements.first_adventure"
|
||||
type = models.CharField(max_length=255, choices=[(tag, tag) for tag in VALID_ACHIEVEMENT_TYPES], default='adventure_count') # adventure_count, country_count, etc.
|
||||
description = models.TextField()
|
||||
icon = models.ImageField(upload_to="achievements/", null=True, blank=True)
|
||||
condition = models.JSONField() # Stores rules like {"type": "adventure_count", "value": 10}
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class UserAchievement(models.Model):
|
||||
"""Tracks which achievements a user has earned"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE)
|
||||
earned_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "achievement") # Prevent duplicates
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.achievement.name}"
|
3
backend/server/achievements/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
3
backend/server/achievements/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
|
@ -1,15 +1,13 @@
|
|||
import os
|
||||
from django.contrib import admin
|
||||
from django.utils.html import mark_safe
|
||||
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category
|
||||
from worldtravel.models import Country, Region, VisitedRegion
|
||||
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Lodging
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
|
||||
|
||||
class AdventureAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
|
||||
list_filter = ( 'user_id', 'is_public')
|
||||
|
@ -53,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin):
|
|||
|
||||
number_of_visits.short_description = 'Number of Visits'
|
||||
|
||||
class CityAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'region', 'country')
|
||||
list_filter = ('region', 'region__country')
|
||||
search_fields = ('name', 'region__name', 'region__country__name')
|
||||
|
||||
def country(self, obj):
|
||||
return obj.region.country.name
|
||||
|
||||
country.short_description = 'Country'
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from users.models import CustomUser
|
||||
|
@ -63,7 +71,7 @@ class CustomUserAdmin(UserAdmin):
|
|||
readonly_fields = ('uuid',)
|
||||
search_fields = ('username',)
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
(None, {'fields': ('profile_pic', 'uuid', 'public_profile')}),
|
||||
(None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password')}),
|
||||
)
|
||||
def image_display(self, obj):
|
||||
if obj.profile_pic:
|
||||
|
@ -129,6 +137,10 @@ admin.site.register(Checklist)
|
|||
admin.site.register(ChecklistItem)
|
||||
admin.site.register(AdventureImage, AdventureImageAdmin)
|
||||
admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(City, CityAdmin)
|
||||
admin.site.register(VisitedCity)
|
||||
admin.site.register(Attachment)
|
||||
admin.site.register(Lodging)
|
||||
|
||||
admin.site.site_header = 'AdventureLog Admin'
|
||||
admin.site.site_title = 'AdventureLog Admin Site'
|
||||
|
|
22
backend/server/adventures/managers.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
class AdventureManager(models.Manager):
|
||||
def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False):
|
||||
# Initialize the query with an empty Q object
|
||||
query = Q()
|
||||
|
||||
# Add owned adventures to the query if included
|
||||
if include_owned:
|
||||
query |= Q(user_id=user.id)
|
||||
|
||||
# Add shared adventures to the query if included
|
||||
if include_shared:
|
||||
query |= Q(collection__shared_with=user.id)
|
||||
|
||||
# Add public adventures to the query if included
|
||||
if include_public:
|
||||
query |= Q(is_public=True)
|
||||
|
||||
# Perform the query with the final Q object and remove duplicates
|
||||
return self.filter(query).distinct()
|
|
@ -1,23 +1,32 @@
|
|||
class AppVersionMiddleware:
|
||||
from django.conf import settings
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
import os
|
||||
|
||||
class OverrideHostMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Process request (if needed)
|
||||
public_url = os.getenv('PUBLIC_URL', None)
|
||||
if public_url:
|
||||
# Extract host and scheme
|
||||
scheme, host = public_url.split("://")
|
||||
request.META['HTTP_HOST'] = host
|
||||
request.META['wsgi.url_scheme'] = scheme
|
||||
|
||||
# Set X-Forwarded-Proto for Django
|
||||
request.META['HTTP_X_FORWARDED_PROTO'] = scheme
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
# Add custom header to response
|
||||
# Replace with your app version
|
||||
response['X-AdventureLog-Version'] = '1.0.0'
|
||||
|
||||
return response
|
||||
|
||||
# make a middlewra that prints all of the request cookies
|
||||
class PrintCookiesMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
class XSessionTokenMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
session_token = request.headers.get('X-Session-Token')
|
||||
if session_token:
|
||||
request.COOKIES[settings.SESSION_COOKIE_NAME] = session_token
|
||||
|
||||
def __call__(self, request):
|
||||
print(request.COOKIES)
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
class DisableCSRFForSessionTokenMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
if 'X-Session-Token' in request.headers:
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-01 21:40
|
||||
|
||||
import adventures.models
|
||||
import django_resized.forms
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0015_transportation_destination_latitude_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='adventureimage',
|
||||
name='image',
|
||||
field=django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-03 04:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0016_alter_adventureimage_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='adventureimage',
|
||||
name='is_primary',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
26
backend/server/adventures/migrations/0018_attachment.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-19 00:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0017_adventureimage_is_primary'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Attachment',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('file', models.FileField(upload_to='attachments/')),
|
||||
('adventure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='adventures.adventure')),
|
||||
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-19 22:17
|
||||
|
||||
import adventures.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0018_attachment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attachment',
|
||||
name='file',
|
||||
field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/')),
|
||||
),
|
||||
]
|
19
backend/server/adventures/migrations/0020_attachment_name.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-19 22:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0019_alter_attachment_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='attachment',
|
||||
name='name',
|
||||
field=models.CharField(default='', max_length=200),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-19 22:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0020_attachment_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attachment',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=200, null=True),
|
||||
),
|
||||
]
|
39
backend/server/adventures/migrations/0022_hotel.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Generated by Django 5.0.8 on 2025-02-02 15:36
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0021_alter_attachment_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Hotel',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('rating', models.FloatField(blank=True, null=True)),
|
||||
('link', models.URLField(blank=True, max_length=2083, null=True)),
|
||||
('check_in', models.DateTimeField(blank=True, null=True)),
|
||||
('check_out', models.DateTimeField(blank=True, null=True)),
|
||||
('reservation_number', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True)),
|
||||
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('location', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('is_public', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')),
|
||||
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 5.0.8 on 2025-02-08 01:50
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0022_hotel'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Lodging',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('type', models.CharField(choices=[('hotel', 'Hotel'), ('hostel', 'Hostel'), ('resort', 'Resort'), ('bnb', 'Bed & Breakfast'), ('campground', 'Campground'), ('cabin', 'Cabin'), ('apartment', 'Apartment'), ('house', 'House'), ('villa', 'Villa'), ('motel', 'Motel'), ('other', 'Other')], default='other', max_length=100)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('rating', models.FloatField(blank=True, null=True)),
|
||||
('link', models.URLField(blank=True, max_length=2083, null=True)),
|
||||
('check_in', models.DateTimeField(blank=True, null=True)),
|
||||
('check_out', models.DateTimeField(blank=True, null=True)),
|
||||
('reservation_number', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True)),
|
||||
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('location', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('is_public', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')),
|
||||
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Hotel',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.0.8 on 2025-03-17 01:15
|
||||
|
||||
import adventures.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0023_lodging_delete_hotel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attachment',
|
||||
name='file',
|
||||
field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/'), validators=[adventures.models.validate_file_extension]),
|
||||
),
|
||||
]
|
|
@ -1,13 +1,23 @@
|
|||
from collections.abc import Collection
|
||||
from django.core.exceptions import ValidationError
|
||||
import os
|
||||
from typing import Iterable
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from adventures.managers import AdventureManager
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.forms import ValidationError
|
||||
from django_resized import ResizedImageField
|
||||
|
||||
def validate_file_extension(value):
|
||||
import os
|
||||
from django.core.exceptions import ValidationError
|
||||
ext = os.path.splitext(value.name)[1] # [0] returns path+filename
|
||||
valid_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.wma', '.aac', '.opus', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.lz4', '.lzma', '.lzo', '.z', '.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tar.lz4', '.tar.lzma', '.tar.lzo', '.tar.z', 'gpx', 'md', 'pdf']
|
||||
if not ext.lower() in valid_extensions:
|
||||
raise ValidationError('Unsupported file extension.')
|
||||
|
||||
ADVENTURE_TYPES = [
|
||||
('general', 'General 🌍'),
|
||||
('outdoor', 'Outdoor 🏞️'),
|
||||
|
@ -33,6 +43,20 @@ ADVENTURE_TYPES = [
|
|||
('other', 'Other')
|
||||
]
|
||||
|
||||
LODGING_TYPES = [
|
||||
('hotel', 'Hotel'),
|
||||
('hostel', 'Hostel'),
|
||||
('resort', 'Resort'),
|
||||
('bnb', 'Bed & Breakfast'),
|
||||
('campground', 'Campground'),
|
||||
('cabin', 'Cabin'),
|
||||
('apartment', 'Apartment'),
|
||||
('house', 'House'),
|
||||
('villa', 'Villa'),
|
||||
('motel', 'Motel'),
|
||||
('other', 'Other')
|
||||
]
|
||||
|
||||
TRANSPORTATION_TYPES = [
|
||||
('car', 'Car'),
|
||||
('plane', 'Plane'),
|
||||
|
@ -86,6 +110,8 @@ class Adventure(models.Model):
|
|||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = AdventureManager()
|
||||
|
||||
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
|
||||
# Migrations performed in this version will remove these fields
|
||||
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
|
||||
|
@ -257,15 +283,42 @@ class ChecklistItem(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@deconstructible
|
||||
class PathAndRename:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def __call__(self, instance, filename):
|
||||
ext = filename.split('.')[-1]
|
||||
# Generate a new UUID for the filename
|
||||
filename = f"{uuid.uuid4()}.{ext}"
|
||||
return os.path.join(self.path, filename)
|
||||
|
||||
class AdventureImage(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
image = ResizedImageField(force_format="WEBP", quality=75, upload_to='images/')
|
||||
image = ResizedImageField(
|
||||
force_format="WEBP",
|
||||
quality=75,
|
||||
upload_to=PathAndRename('images/') # Use the callable class here
|
||||
)
|
||||
adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.image.url
|
||||
|
||||
class Attachment(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
file = models.FileField(upload_to=PathAndRename('attachments/'),validators=[validate_file_extension])
|
||||
adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=200, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.file.url
|
||||
|
||||
class Category(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
|
@ -286,4 +339,38 @@ class Category(models.Model):
|
|||
|
||||
|
||||
def __str__(self):
|
||||
return self.name + ' - ' + self.display_name + ' - ' + self.icon
|
||||
return self.name + ' - ' + self.display_name + ' - ' + self.icon
|
||||
|
||||
class Lodging(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
name = models.CharField(max_length=200)
|
||||
type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other')
|
||||
description = models.TextField(blank=True, null=True)
|
||||
rating = models.FloatField(blank=True, null=True)
|
||||
link = models.URLField(blank=True, null=True, max_length=2083)
|
||||
check_in = models.DateTimeField(blank=True, null=True)
|
||||
check_out = models.DateTimeField(blank=True, null=True)
|
||||
reservation_number = models.CharField(max_length=100, blank=True, null=True)
|
||||
price = models.DecimalField(max_digits=9, decimal_places=2, blank=True, null=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
location = models.CharField(max_length=200, blank=True, null=True)
|
||||
is_public = models.BooleanField(default=False)
|
||||
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def clean(self):
|
||||
if self.date and self.end_date and self.date > self.end_date:
|
||||
raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date))
|
||||
|
||||
if self.collection:
|
||||
if self.collection.is_public and not self.is_public:
|
||||
raise ValidationError('Lodging associated with a public collection must be public. Collection: ' + self.collection.name + ' Loging: ' + self.name)
|
||||
if self.user_id != self.collection.user_id:
|
||||
raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Lodging owner: ' + self.user_id.username)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
|
@ -61,6 +61,10 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
|||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
|
||||
# Allow GET only for a public object
|
||||
if request.method in permissions.SAFE_METHODS and obj.is_public:
|
||||
return True
|
||||
# Check if the object has a collection
|
||||
if hasattr(obj, 'collection') and obj.collection:
|
||||
# Allow all actions for shared users
|
||||
|
@ -71,27 +75,5 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
|||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Allow all actions for the owner
|
||||
return obj.user_id == request.user
|
||||
|
||||
class IsPublicOrOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission to allow:
|
||||
- Read-only access for public objects
|
||||
- Full access for shared users
|
||||
- Full access for owners
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Allow read-only access for public objects
|
||||
if obj.is_public and request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Check if the object has a collection
|
||||
if hasattr(obj, 'collection') and obj.collection:
|
||||
# Allow all actions for shared users
|
||||
if request.user in obj.collection.shared_with.all():
|
||||
return True
|
||||
|
||||
# Allow all actions for the owner
|
||||
return obj.user_id == request.user
|
|
@ -1,15 +1,16 @@
|
|||
from django.utils import timezone
|
||||
import os
|
||||
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category
|
||||
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Lodging
|
||||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
from users.serializers import CustomUserDetailsSerializer
|
||||
|
||||
|
||||
class AdventureImageSerializer(CustomModelSerializer):
|
||||
class Meta:
|
||||
model = AdventureImage
|
||||
fields = ['id', 'image', 'adventure']
|
||||
read_only_fields = ['id']
|
||||
fields = ['id', 'image', 'adventure', 'is_primary', 'user_id']
|
||||
read_only_fields = ['id', 'user_id']
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
|
@ -21,6 +22,26 @@ class AdventureImageSerializer(CustomModelSerializer):
|
|||
representation['image'] = f"{public_url}/media/{instance.image.name}"
|
||||
return representation
|
||||
|
||||
class AttachmentSerializer(CustomModelSerializer):
|
||||
extension = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = Attachment
|
||||
fields = ['id', 'file', 'adventure', 'extension', 'name', 'user_id']
|
||||
read_only_fields = ['id', 'user_id']
|
||||
|
||||
def get_extension(self, obj):
|
||||
return obj.file.name.split('.')[-1]
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
if instance.file:
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
#print(public_url)
|
||||
# remove any ' from the url
|
||||
public_url = public_url.replace("'", "")
|
||||
representation['file'] = f"{public_url}/media/{instance.file.name}"
|
||||
return representation
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
num_adventures = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
|
@ -57,17 +78,19 @@ class VisitSerializer(serializers.ModelSerializer):
|
|||
class AdventureSerializer(CustomModelSerializer):
|
||||
images = AdventureImageSerializer(many=True, read_only=True)
|
||||
visits = VisitSerializer(many=True, read_only=False, required=False)
|
||||
attachments = AttachmentSerializer(many=True, read_only=True)
|
||||
category = CategorySerializer(read_only=False, required=False)
|
||||
is_visited = serializers.SerializerMethodField()
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Adventure
|
||||
fields = [
|
||||
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location',
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude',
|
||||
'latitude', 'visits', 'is_visited', 'category'
|
||||
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user']
|
||||
|
||||
def validate_category(self, category_data):
|
||||
if isinstance(category_data, Category):
|
||||
|
@ -105,7 +128,11 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
}
|
||||
)
|
||||
return category
|
||||
|
||||
|
||||
def get_user(self, obj):
|
||||
user = obj.user_id
|
||||
return CustomUserDetailsSerializer(user).data
|
||||
|
||||
def get_is_visited(self, obj):
|
||||
current_date = timezone.now().date()
|
||||
for visit in obj.visits.all():
|
||||
|
@ -116,7 +143,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
visits_data = validated_data.pop('visits', [])
|
||||
visits_data = validated_data.pop('visits', None)
|
||||
category_data = validated_data.pop('category', None)
|
||||
print(category_data)
|
||||
adventure = Adventure.objects.create(**validated_data)
|
||||
|
@ -131,6 +158,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
return adventure
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
has_visits = 'visits' in validated_data
|
||||
visits_data = validated_data.pop('visits', [])
|
||||
category_data = validated_data.pop('category', None)
|
||||
|
||||
|
@ -142,24 +170,25 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
instance.category = category
|
||||
instance.save()
|
||||
|
||||
current_visits = instance.visits.all()
|
||||
current_visit_ids = set(current_visits.values_list('id', flat=True))
|
||||
if has_visits:
|
||||
current_visits = instance.visits.all()
|
||||
current_visit_ids = set(current_visits.values_list('id', flat=True))
|
||||
|
||||
updated_visit_ids = set()
|
||||
for visit_data in visits_data:
|
||||
visit_id = visit_data.get('id')
|
||||
if visit_id and visit_id in current_visit_ids:
|
||||
visit = current_visits.get(id=visit_id)
|
||||
for attr, value in visit_data.items():
|
||||
setattr(visit, attr, value)
|
||||
visit.save()
|
||||
updated_visit_ids.add(visit_id)
|
||||
else:
|
||||
new_visit = Visit.objects.create(adventure=instance, **visit_data)
|
||||
updated_visit_ids.add(new_visit.id)
|
||||
updated_visit_ids = set()
|
||||
for visit_data in visits_data:
|
||||
visit_id = visit_data.get('id')
|
||||
if visit_id and visit_id in current_visit_ids:
|
||||
visit = current_visits.get(id=visit_id)
|
||||
for attr, value in visit_data.items():
|
||||
setattr(visit, attr, value)
|
||||
visit.save()
|
||||
updated_visit_ids.add(visit_id)
|
||||
else:
|
||||
new_visit = Visit.objects.create(adventure=instance, **visit_data)
|
||||
updated_visit_ids.add(new_visit.id)
|
||||
|
||||
visits_to_delete = current_visit_ids - updated_visit_ids
|
||||
instance.visits.filter(id__in=visits_to_delete).delete()
|
||||
visits_to_delete = current_visit_ids - updated_visit_ids
|
||||
instance.visits.filter(id__in=visits_to_delete).delete()
|
||||
|
||||
return instance
|
||||
|
||||
|
@ -174,6 +203,17 @@ class TransportationSerializer(CustomModelSerializer):
|
|||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
|
||||
|
||||
class LodgingSerializer(CustomModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Lodging
|
||||
fields = [
|
||||
'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
|
||||
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
|
||||
'collection', 'created_at', 'updated_at', 'type'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
|
||||
|
||||
class NoteSerializer(CustomModelSerializer):
|
||||
|
||||
class Meta:
|
||||
|
@ -260,10 +300,11 @@ class CollectionSerializer(CustomModelSerializer):
|
|||
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
|
||||
notes = NoteSerializer(many=True, read_only=True, source='note_set')
|
||||
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')
|
||||
lodging = LodgingSerializer(many=True, read_only=True, source='lodging_set')
|
||||
|
||||
class Meta:
|
||||
model = Collection
|
||||
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link']
|
||||
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
@ -273,5 +314,4 @@ class CollectionSerializer(CustomModelSerializer):
|
|||
for user in instance.shared_with.all():
|
||||
shared_uuids.append(str(user.uuid))
|
||||
representation['shared_with'] = shared_uuids
|
||||
return representation
|
||||
|
||||
return representation
|
|
@ -1,6 +1,6 @@
|
|||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet, IcsCalendarGeneratorViewSet
|
||||
from adventures.views import *
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'adventures', AdventureViewSet, basename='adventures')
|
||||
|
@ -15,6 +15,10 @@ router.register(r'images', AdventureImageViewSet, basename='images')
|
|||
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
||||
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||
router.register(r'overpass', OverpassViewSet, basename='overpass')
|
||||
router.register(r'search', GlobalSearchView, basename='search')
|
||||
router.register(r'attachments', AttachmentViewSet, basename='attachments')
|
||||
router.register(r'lodging', LodgingViewSet, basename='lodging')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
|
41
backend/server/adventures/utils/file_permissions.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from adventures.models import AdventureImage, Attachment
|
||||
|
||||
protected_paths = ['images/', 'attachments/']
|
||||
|
||||
def checkFilePermission(fileId, user, mediaType):
|
||||
if mediaType not in protected_paths:
|
||||
return True
|
||||
if mediaType == 'images/':
|
||||
try:
|
||||
# Construct the full relative path to match the database field
|
||||
image_path = f"images/{fileId}"
|
||||
# Fetch the AdventureImage object
|
||||
adventure = AdventureImage.objects.get(image=image_path).adventure
|
||||
if adventure.is_public:
|
||||
return True
|
||||
elif adventure.user_id == user:
|
||||
return True
|
||||
elif adventure.collection:
|
||||
if adventure.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except AdventureImage.DoesNotExist:
|
||||
return False
|
||||
elif mediaType == 'attachments/':
|
||||
try:
|
||||
# Construct the full relative path to match the database field
|
||||
attachment_path = f"attachments/{fileId}"
|
||||
# Fetch the Attachment object
|
||||
attachment = Attachment.objects.get(file=attachment_path).adventure
|
||||
if attachment.is_public:
|
||||
return True
|
||||
elif attachment.user_id == user:
|
||||
return True
|
||||
elif attachment.collection:
|
||||
if attachment.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Attachment.DoesNotExist:
|
||||
return False
|
6
backend/server/adventures/utils/pagination.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 25
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 1000
|
16
backend/server/adventures/views/__init__.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from .activity_types_view import *
|
||||
from .adventure_image_view import *
|
||||
from .adventure_view import *
|
||||
from .category_view import *
|
||||
from .checklist_view import *
|
||||
from .collection_view import *
|
||||
from .generate_description_view import *
|
||||
from .ics_calendar_view import *
|
||||
from .note_view import *
|
||||
from .overpass_view import *
|
||||
from .reverse_geocode_view import *
|
||||
from .stats_view import *
|
||||
from .transportation_view import *
|
||||
from .global_search_view import *
|
||||
from .attachment_view import *
|
||||
from .lodging_view import *
|
32
backend/server/adventures/views/activity_types_view.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Adventure
|
||||
|
||||
class ActivityTypesView(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def types(self, request):
|
||||
"""
|
||||
Retrieve a list of distinct activity types for adventures associated with the current user.
|
||||
|
||||
Args:
|
||||
request (HttpRequest): The HTTP request object.
|
||||
|
||||
Returns:
|
||||
Response: A response containing a list of distinct activity types.
|
||||
"""
|
||||
types = Adventure.objects.filter(user_id=request.user.id).values_list('activity_types', flat=True).distinct()
|
||||
|
||||
allTypes = []
|
||||
|
||||
for i in types:
|
||||
if not i:
|
||||
continue
|
||||
for x in i:
|
||||
if x and x not in allTypes:
|
||||
allTypes.append(x)
|
||||
|
||||
return Response(allTypes)
|
124
backend/server/adventures/views/adventure_image_view.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from adventures.models import Adventure, AdventureImage
|
||||
from adventures.serializers import AdventureImageSerializer
|
||||
import uuid
|
||||
|
||||
class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = AdventureImageSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def image_delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_primary(self, request, *args, **kwargs):
|
||||
# Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
instance = self.get_object()
|
||||
adventure = instance.adventure
|
||||
if adventure.user_id != request.user:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Check if the image is already the primary image
|
||||
if instance.is_primary:
|
||||
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Set the current primary image to false
|
||||
AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
|
||||
|
||||
# Set the new image to true
|
||||
instance.is_primary = True
|
||||
instance.save()
|
||||
return Response({"success": "Image set as primary image"})
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
adventure_id = request.data.get('adventure')
|
||||
try:
|
||||
adventure = Adventure.objects.get(id=adventure_id)
|
||||
except Adventure.DoesNotExist:
|
||||
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if adventure.user_id != request.user:
|
||||
# Check if the adventure has a collection
|
||||
if adventure.collection:
|
||||
# Check if the user is in the collection's shared_with list
|
||||
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
|
||||
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
adventure_id = request.data.get('adventure')
|
||||
try:
|
||||
adventure = Adventure.objects.get(id=adventure_id)
|
||||
except Adventure.DoesNotExist:
|
||||
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if adventure.user_id != request.user:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
print("perform_destroy")
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
print("destroy")
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
instance = self.get_object()
|
||||
adventure = instance.adventure
|
||||
if adventure.user_id != request.user:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
instance = self.get_object()
|
||||
adventure = instance.adventure
|
||||
if adventure.user_id != request.user:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@action(detail=False, methods=['GET'], url_path='(?P<adventure_id>[0-9a-f-]+)')
|
||||
def adventure_images(self, request, adventure_id=None, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
try:
|
||||
adventure_uuid = uuid.UUID(adventure_id)
|
||||
except ValueError:
|
||||
return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
queryset = AdventureImage.objects.filter(
|
||||
Q(adventure__id=adventure_uuid) & Q(user_id=request.user)
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
return AdventureImage.objects.filter(user_id=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user_id=self.request.user)
|
217
backend/server/adventures/views/adventure_view.py
Normal file
|
@ -0,0 +1,217 @@
|
|||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q, Max
|
||||
from django.db.models.functions import Lower
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Adventure, Category, Transportation, Lodging
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer
|
||||
from adventures.utils import pagination
|
||||
|
||||
class AdventureViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = AdventureSerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
pagination_class = pagination.StandardResultsSetPagination
|
||||
|
||||
def apply_sorting(self, queryset):
|
||||
order_by = self.request.query_params.get('order_by', 'updated_at')
|
||||
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||
include_collections = self.request.query_params.get('include_collections', 'true')
|
||||
|
||||
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
|
||||
if order_by not in valid_order_by:
|
||||
order_by = 'name'
|
||||
|
||||
if order_direction not in ['asc', 'desc']:
|
||||
order_direction = 'asc'
|
||||
|
||||
if order_by == 'date':
|
||||
queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False)
|
||||
ordering = 'latest_visit'
|
||||
elif order_by == 'name':
|
||||
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||
ordering = 'lower_name'
|
||||
elif order_by == 'rating':
|
||||
queryset = queryset.filter(rating__isnull=False)
|
||||
ordering = 'rating'
|
||||
else:
|
||||
ordering = order_by
|
||||
|
||||
if order_direction == 'desc':
|
||||
ordering = f'-{ordering}'
|
||||
|
||||
if order_by == 'updated_at':
|
||||
ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
|
||||
|
||||
if include_collections == 'false':
|
||||
queryset = queryset.filter(collection=None)
|
||||
|
||||
return queryset.order_by(ordering)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Returns the queryset for the AdventureViewSet. Unauthenticated users can only
|
||||
retrieve public adventures, while authenticated users can access their own,
|
||||
shared, and public adventures depending on the action.
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
# Unauthenticated users can only access public adventures for retrieval
|
||||
if self.action == 'retrieve':
|
||||
return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at')
|
||||
return Adventure.objects.none()
|
||||
|
||||
# Authenticated users: Handle retrieval separately
|
||||
include_public = self.action == 'retrieve'
|
||||
return Adventure.objects.retrieve_adventures(
|
||||
user,
|
||||
include_public=include_public,
|
||||
include_owned=True,
|
||||
include_shared=True
|
||||
).order_by('-updated_at')
|
||||
|
||||
def perform_update(self, serializer):
|
||||
adventure = serializer.save()
|
||||
if adventure.collection:
|
||||
adventure.is_public = adventure.collection.is_public
|
||||
adventure.save()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def filtered(self, request):
|
||||
types = request.query_params.get('types', '').split(',')
|
||||
is_visited = request.query_params.get('is_visited', 'all')
|
||||
|
||||
if 'all' in types:
|
||||
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
|
||||
else:
|
||||
if not types or not all(
|
||||
Category.objects.filter(user_id=request.user, name=type).exists() for type in types
|
||||
):
|
||||
return Response({"error": "Invalid category or no types provided"}, status=400)
|
||||
|
||||
queryset = Adventure.objects.filter(
|
||||
category__in=Category.objects.filter(name__in=types, user_id=request.user),
|
||||
user_id=request.user.id
|
||||
)
|
||||
|
||||
is_visited_param = request.query_params.get('is_visited')
|
||||
if is_visited_param is not None:
|
||||
# Convert is_visited_param to a boolean
|
||||
if is_visited_param.lower() == 'true':
|
||||
is_visited_bool = True
|
||||
elif is_visited_param.lower() == 'false':
|
||||
is_visited_bool = False
|
||||
else:
|
||||
is_visited_bool = None
|
||||
|
||||
# Filter logic: "visited" means at least one visit with start_date <= today
|
||||
now = timezone.now().date()
|
||||
if is_visited_bool is True:
|
||||
queryset = queryset.filter(visits__start_date__lte=now).distinct()
|
||||
elif is_visited_bool is False:
|
||||
queryset = queryset.exclude(visits__start_date__lte=now).distinct()
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
return self.paginate_and_respond(queryset, request)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def all(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
|
||||
include_collections = request.query_params.get('include_collections', 'false') == 'true'
|
||||
queryset = Adventure.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=request.user.id),
|
||||
collection=None if not include_collections else Q()
|
||||
)
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
if new_collection and new_collection!=instance.collection:
|
||||
if new_collection.user_id != request.user or instance.user_id != request.user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None and instance.collection and instance.collection.user_id != request.user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
self.perform_update(serializer)
|
||||
return Response(serializer.data)
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()):
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif collection:
|
||||
serializer.save(user_id=collection.user_id, is_public=collection.is_public)
|
||||
return
|
||||
|
||||
serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False)
|
||||
|
||||
def paginate_and_respond(self, queryset, request):
|
||||
paginator = self.pagination_class()
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
# @action(detail=True, methods=['post'])
|
||||
# def convert(self, request, pk=None):
|
||||
# """
|
||||
# Convert an Adventure instance into a Transportation or Lodging instance.
|
||||
# Expects a JSON body with "target_type": "transportation" or "lodging".
|
||||
# """
|
||||
# adventure = self.get_object()
|
||||
# target_type = request.data.get("target_type", "").lower()
|
||||
|
||||
# if target_type not in ["transportation", "lodging"]:
|
||||
# return Response(
|
||||
# {"error": "Invalid target type. Must be 'transportation' or 'lodging'."},
|
||||
# status=400
|
||||
# )
|
||||
# if not adventure.collection:
|
||||
# return Response(
|
||||
# {"error": "Adventure must be part of a collection to be converted."},
|
||||
# status=400
|
||||
# )
|
||||
|
||||
# # Define the overlapping fields that both the Adventure and target models share.
|
||||
# overlapping_fields = ["name", "description", "is_public", 'collection']
|
||||
|
||||
# # Gather the overlapping data from the adventure instance.
|
||||
# conversion_data = {}
|
||||
# for field in overlapping_fields:
|
||||
# if hasattr(adventure, field):
|
||||
# conversion_data[field] = getattr(adventure, field)
|
||||
|
||||
# # Make sure to include the user reference
|
||||
# conversion_data["user_id"] = adventure.user_id
|
||||
|
||||
# # Convert the adventure instance within an atomic transaction.
|
||||
# with transaction.atomic():
|
||||
# if target_type == "transportation":
|
||||
# new_instance = Transportation.objects.create(**conversion_data)
|
||||
# serializer = TransportationSerializer(new_instance)
|
||||
# else: # target_type == "lodging"
|
||||
# new_instance = Lodging.objects.create(**conversion_data)
|
||||
# serializer = LodgingSerializer(new_instance)
|
||||
|
||||
# # Optionally, delete the original adventure to avoid duplicates.
|
||||
# adventure.delete()
|
||||
|
||||
# return Response(serializer.data)
|
40
backend/server/adventures/views/attachment_view.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Adventure, Attachment
|
||||
from adventures.serializers import AttachmentSerializer
|
||||
|
||||
class AttachmentViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = AttachmentSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Attachment.objects.filter(user_id=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def attachment_delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
adventure_id = request.data.get('adventure')
|
||||
try:
|
||||
adventure = Adventure.objects.get(id=adventure_id)
|
||||
except Adventure.DoesNotExist:
|
||||
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if adventure.user_id != request.user:
|
||||
# Check if the adventure has a collection
|
||||
if adventure.collection:
|
||||
# Check if the user is in the collection's shared_with list
|
||||
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
|
||||
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user_id=self.request.user)
|
42
backend/server/adventures/views/category_view.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Category, Adventure
|
||||
from adventures.serializers import CategorySerializer
|
||||
|
||||
class CategoryViewSet(viewsets.ModelViewSet):
|
||||
queryset = Category.objects.all()
|
||||
serializer_class = CategorySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Category.objects.filter(user_id=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def categories(self, request):
|
||||
"""
|
||||
Retrieve a list of distinct categories for adventures associated with the current user.
|
||||
"""
|
||||
categories = self.get_queryset().distinct()
|
||||
serializer = self.get_serializer(categories, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.user_id != request.user:
|
||||
return Response({"error": "User does not own this category"}, status
|
||||
=400)
|
||||
|
||||
if instance.name == 'general':
|
||||
return Response({"error": "Cannot delete the general category"}, status=400)
|
||||
|
||||
# set any adventures with this category to a default category called general before deleting the category, if general does not exist create it for the user
|
||||
general_category = Category.objects.filter(user_id=request.user, name='general').first()
|
||||
|
||||
if not general_category:
|
||||
general_category = Category.objects.create(user_id=request.user, name='general', icon='🌍', display_name='General')
|
||||
|
||||
Adventure.objects.filter(category=instance).update(category=general_category)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
130
backend/server/adventures/views/checklist_view.py
Normal file
|
@ -0,0 +1,130 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from adventures.models import Checklist
|
||||
from adventures.serializers import ChecklistSerializer
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
|
||||
class ChecklistViewSet(viewsets.ModelViewSet):
|
||||
queryset = Checklist.objects.all()
|
||||
serializer_class = ChecklistSerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
filterset_fields = ['is_public', 'collection']
|
||||
|
||||
# return error message if user is not authenticated on the root endpoint
|
||||
def list(self, request, *args, **kwargs):
|
||||
# Prevent listing all adventures
|
||||
return Response({"detail": "Listing all checklists is not allowed."},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def all(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
queryset = Checklist.objects.filter(
|
||||
Q(user_id=request.user.id)
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
# if the user is not authenticated return only public transportations for retrieve action
|
||||
if not self.request.user.is_authenticated:
|
||||
if self.action == 'retrieve':
|
||||
return Checklist.objects.filter(is_public=True).distinct().order_by('-updated_at')
|
||||
return Checklist.objects.none()
|
||||
|
||||
|
||||
if self.action == 'retrieve':
|
||||
# For individual adventure retrieval, include public adventures
|
||||
return Checklist.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||
).distinct().order_by('-updated_at')
|
||||
else:
|
||||
# For other actions, include user's own adventures and shared adventures
|
||||
return Checklist.objects.filter(
|
||||
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||
).distinct().order_by('-updated_at')
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
user = request.user
|
||||
print(new_collection)
|
||||
|
||||
if new_collection is not None and new_collection!=instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
user = request.user
|
||||
print(new_collection)
|
||||
|
||||
if new_collection is not None and new_collection!=instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||
def perform_create(self, serializer):
|
||||
# Retrieve the collection from the validated data
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
# Check if a collection is provided
|
||||
if collection:
|
||||
user = self.request.user
|
||||
# Check if the user is the owner or is in the shared_with list
|
||||
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||
# Return an error response if the user does not have permission
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
# if collection the owner of the adventure is the owner of the collection
|
||||
serializer.save(user_id=collection.user_id)
|
||||
return
|
||||
|
||||
# Save the adventure with the current user as the owner
|
||||
serializer.save(user_id=self.request.user)
|
218
backend/server/adventures/views/collection_view.py
Normal file
|
@ -0,0 +1,218 @@
|
|||
from django.db.models import Q
|
||||
from django.db.models.functions import Lower
|
||||
from django.db import transaction
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Collection, Adventure, Transportation, Note
|
||||
from adventures.permissions import CollectionShared
|
||||
from adventures.serializers import CollectionSerializer
|
||||
from users.models import CustomUser as User
|
||||
from adventures.utils import pagination
|
||||
|
||||
class CollectionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CollectionSerializer
|
||||
permission_classes = [CollectionShared]
|
||||
pagination_class = pagination.StandardResultsSetPagination
|
||||
|
||||
# def get_queryset(self):
|
||||
# return Collection.objects.filter(Q(user_id=self.request.user.id) & Q(is_archived=False))
|
||||
|
||||
def apply_sorting(self, queryset):
|
||||
order_by = self.request.query_params.get('order_by', 'name')
|
||||
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||
|
||||
valid_order_by = ['name', 'upated_at']
|
||||
if order_by not in valid_order_by:
|
||||
order_by = 'updated_at'
|
||||
|
||||
if order_direction not in ['asc', 'desc']:
|
||||
order_direction = 'asc'
|
||||
|
||||
# Apply case-insensitive sorting for the 'name' field
|
||||
if order_by == 'name':
|
||||
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||
ordering = 'lower_name'
|
||||
if order_direction == 'desc':
|
||||
ordering = f'-{ordering}'
|
||||
else:
|
||||
order_by == 'updated_at'
|
||||
ordering = 'updated_at'
|
||||
if order_direction == 'asc':
|
||||
ordering = '-updated_at'
|
||||
|
||||
#print(f"Ordering by: {ordering}") # For debugging
|
||||
|
||||
return queryset.order_by(ordering)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
# make sure the user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
queryset = Collection.objects.filter(user_id=request.user.id)
|
||||
queryset = self.apply_sorting(queryset)
|
||||
collections = self.paginate_and_respond(queryset, request)
|
||||
return collections
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def all(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user_id=request.user.id)
|
||||
)
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def archived(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user_id=request.user.id) & Q(is_archived=True)
|
||||
)
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
# this make the is_public field of the collection cascade to the adventures
|
||||
@transaction.atomic
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if 'collection' in serializer.validated_data:
|
||||
new_collection = serializer.validated_data['collection']
|
||||
# if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error
|
||||
if new_collection != instance.collection and new_collection.user_id != request.user:
|
||||
return Response({"error": "User does not own the new collection"}, status=400)
|
||||
|
||||
# Check if the 'is_public' field is present in the update data
|
||||
if 'is_public' in serializer.validated_data:
|
||||
new_public_status = serializer.validated_data['is_public']
|
||||
|
||||
# if is_publuc has changed and the user is not the owner of the collection return an error
|
||||
if new_public_status != instance.is_public and instance.user_id != request.user:
|
||||
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}")
|
||||
return Response({"error": "User does not own the collection"}, status=400)
|
||||
|
||||
# Update associated adventures to match the collection's is_public status
|
||||
Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
|
||||
# do the same for transportations
|
||||
Transportation.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
|
||||
# do the same for notes
|
||||
Note.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
|
||||
# Log the action (optional)
|
||||
action = "public" if new_public_status else "private"
|
||||
print(f"Collection {instance.id} and its adventures were set to {action}")
|
||||
|
||||
self.perform_update(serializer)
|
||||
|
||||
if getattr(instance, '_prefetched_objects_cache', None):
|
||||
# If 'prefetch_related' has been applied to a queryset, we need to
|
||||
# forcibly invalidate the prefetch cache on the instance.
|
||||
instance._prefetched_objects_cache = {}
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
# make an action to retreive all adventures that are shared with the user
|
||||
@action(detail=False, methods=['get'])
|
||||
def shared(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
queryset = Collection.objects.filter(
|
||||
shared_with=request.user
|
||||
)
|
||||
queryset = self.apply_sorting(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
# Adds a new user to the shared_with field of an adventure
|
||||
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
|
||||
def share(self, request, pk=None, uuid=None):
|
||||
collection = self.get_object()
|
||||
if not uuid:
|
||||
return Response({"error": "User UUID is required"}, status=400)
|
||||
try:
|
||||
user = User.objects.get(uuid=uuid, public_profile=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({"error": "User not found"}, status=404)
|
||||
|
||||
if user == request.user:
|
||||
return Response({"error": "Cannot share with yourself"}, status=400)
|
||||
|
||||
if collection.shared_with.filter(id=user.id).exists():
|
||||
return Response({"error": "Adventure is already shared with this user"}, status=400)
|
||||
|
||||
collection.shared_with.add(user)
|
||||
collection.save()
|
||||
return Response({"success": f"Shared with {user.username}"})
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='unshare/(?P<uuid>[^/.]+)')
|
||||
def unshare(self, request, pk=None, uuid=None):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
collection = self.get_object()
|
||||
if not uuid:
|
||||
return Response({"error": "User UUID is required"}, status=400)
|
||||
try:
|
||||
user = User.objects.get(uuid=uuid, public_profile=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({"error": "User not found"}, status=404)
|
||||
|
||||
if user == request.user:
|
||||
return Response({"error": "Cannot unshare with yourself"}, status=400)
|
||||
|
||||
if not collection.shared_with.filter(id=user.id).exists():
|
||||
return Response({"error": "Collection is not shared with this user"}, status=400)
|
||||
|
||||
collection.shared_with.remove(user)
|
||||
collection.save()
|
||||
return Response({"success": f"Unshared with {user.username}"})
|
||||
|
||||
def get_queryset(self):
|
||||
if self.action == 'destroy':
|
||||
return Collection.objects.filter(user_id=self.request.user.id)
|
||||
|
||||
if self.action in ['update', 'partial_update']:
|
||||
return Collection.objects.filter(
|
||||
Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
|
||||
).distinct()
|
||||
|
||||
if self.action == 'retrieve':
|
||||
if not self.request.user.is_authenticated:
|
||||
return Collection.objects.filter(is_public=True)
|
||||
return Collection.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
|
||||
).distinct()
|
||||
|
||||
# For list action, include collections owned by the user or shared with the user, that are not archived
|
||||
return Collection.objects.filter(
|
||||
(Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False)
|
||||
).distinct()
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# This is ok because you cannot share a collection when creating it
|
||||
serializer.save(user_id=self.request.user)
|
||||
|
||||
def paginate_and_respond(self, queryset, request):
|
||||
paginator = self.pagination_class()
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
44
backend/server/adventures/views/generate_description_view.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
import requests
|
||||
|
||||
class GenerateDescription(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['get'],)
|
||||
def desc(self, request):
|
||||
name = self.request.query_params.get('name', '')
|
||||
# un url encode the name
|
||||
name = name.replace('%20', ' ')
|
||||
name = self.get_search_term(name)
|
||||
url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
data = response.json()
|
||||
page_id = next(iter(data["query"]["pages"]))
|
||||
extract = data["query"]["pages"][page_id]
|
||||
if extract.get('extract') is None:
|
||||
return Response({"error": "No description found"}, status=400)
|
||||
return Response(extract)
|
||||
@action(detail=False, methods=['get'],)
|
||||
def img(self, request):
|
||||
name = self.request.query_params.get('name', '')
|
||||
# un url encode the name
|
||||
name = name.replace('%20', ' ')
|
||||
name = self.get_search_term(name)
|
||||
url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
page_id = next(iter(data["query"]["pages"]))
|
||||
extract = data["query"]["pages"][page_id]
|
||||
if extract.get('original') is None:
|
||||
return Response({"error": "No image found"}, status=400)
|
||||
return Response(extract["original"])
|
||||
|
||||
def get_search_term(self, term):
|
||||
response = requests.get(f'https://en.wikipedia.org/w/api.php?action=opensearch&search={term}&limit=10&namespace=0&format=json')
|
||||
data = response.json()
|
||||
if data[1] and len(data[1]) > 0:
|
||||
return data[1][0]
|
73
backend/server/adventures/views/global_search_view.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
from django.contrib.postgres.search import SearchVector, SearchQuery
|
||||
from adventures.models import Adventure, Collection
|
||||
from adventures.serializers import AdventureSerializer, CollectionSerializer
|
||||
from worldtravel.models import Country, Region, City, VisitedCity, VisitedRegion
|
||||
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer, VisitedCitySerializer, VisitedRegionSerializer
|
||||
from users.models import CustomUser as User
|
||||
from users.serializers import CustomUserDetailsSerializer as UserSerializer
|
||||
|
||||
class GlobalSearchView(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def list(self, request):
|
||||
search_term = request.query_params.get('query', '').strip()
|
||||
if not search_term:
|
||||
return Response({"error": "Search query is required"}, status=400)
|
||||
|
||||
# Initialize empty results
|
||||
results = {
|
||||
"adventures": [],
|
||||
"collections": [],
|
||||
"users": [],
|
||||
"countries": [],
|
||||
"regions": [],
|
||||
"cities": [],
|
||||
"visited_regions": [],
|
||||
"visited_cities": []
|
||||
}
|
||||
|
||||
# Adventures: Full-Text Search
|
||||
adventures = Adventure.objects.annotate(
|
||||
search=SearchVector('name', 'description', 'location')
|
||||
).filter(search=SearchQuery(search_term), user_id=request.user)
|
||||
results["adventures"] = AdventureSerializer(adventures, many=True).data
|
||||
|
||||
# Collections: Partial Match Search
|
||||
collections = Collection.objects.filter(
|
||||
Q(name__icontains=search_term) & Q(user_id=request.user)
|
||||
)
|
||||
results["collections"] = CollectionSerializer(collections, many=True).data
|
||||
|
||||
# Users: Public Profiles Only
|
||||
users = User.objects.filter(
|
||||
(Q(username__icontains=search_term) |
|
||||
Q(first_name__icontains=search_term) |
|
||||
Q(last_name__icontains=search_term)) & Q(public_profile=True)
|
||||
)
|
||||
results["users"] = UserSerializer(users, many=True).data
|
||||
|
||||
# Countries: Full-Text Search
|
||||
countries = Country.objects.annotate(
|
||||
search=SearchVector('name', 'country_code')
|
||||
).filter(search=SearchQuery(search_term))
|
||||
results["countries"] = CountrySerializer(countries, many=True).data
|
||||
|
||||
# Regions and Cities: Partial Match Search
|
||||
regions = Region.objects.filter(Q(name__icontains=search_term))
|
||||
results["regions"] = RegionSerializer(regions, many=True).data
|
||||
|
||||
cities = City.objects.filter(Q(name__icontains=search_term))
|
||||
results["cities"] = CitySerializer(cities, many=True).data
|
||||
|
||||
# Visited Regions and Cities
|
||||
visited_regions = VisitedRegion.objects.filter(user_id=request.user)
|
||||
results["visited_regions"] = VisitedRegionSerializer(visited_regions, many=True).data
|
||||
|
||||
visited_cities = VisitedCity.objects.filter(user_id=request.user)
|
||||
results["visited_cities"] = VisitedCitySerializer(visited_cities, many=True).data
|
||||
|
||||
return Response(results)
|
67
backend/server/adventures/views/ics_calendar_view.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
from django.http import HttpResponse
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from icalendar import Calendar, Event, vText, vCalAddress
|
||||
from datetime import datetime, timedelta
|
||||
from adventures.models import Adventure
|
||||
from adventures.serializers import AdventureSerializer
|
||||
|
||||
class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def generate(self, request):
|
||||
adventures = Adventure.objects.filter(user_id=request.user)
|
||||
serializer = AdventureSerializer(adventures, many=True)
|
||||
user = request.user
|
||||
name = f"{user.first_name} {user.last_name}"
|
||||
print(serializer.data)
|
||||
|
||||
cal = Calendar()
|
||||
cal.add('prodid', '-//My Adventure Calendar//example.com//')
|
||||
cal.add('version', '2.0')
|
||||
|
||||
for adventure in serializer.data:
|
||||
if adventure['visits']:
|
||||
for visit in adventure['visits']:
|
||||
# Skip if start_date is missing
|
||||
if not visit.get('start_date'):
|
||||
continue
|
||||
|
||||
# Parse start_date and handle end_date
|
||||
try:
|
||||
start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
continue # Skip if the start_date is invalid
|
||||
|
||||
end_date = (
|
||||
datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1)
|
||||
if visit.get('end_date') else start_date + timedelta(days=1)
|
||||
)
|
||||
|
||||
# Create event
|
||||
event = Event()
|
||||
event.add('summary', adventure['name'])
|
||||
event.add('dtstart', start_date)
|
||||
event.add('dtend', end_date)
|
||||
event.add('dtstamp', datetime.now())
|
||||
event.add('transp', 'TRANSPARENT')
|
||||
event.add('class', 'PUBLIC')
|
||||
event.add('created', datetime.now())
|
||||
event.add('last-modified', datetime.now())
|
||||
event.add('description', adventure['description'])
|
||||
if adventure.get('location'):
|
||||
event.add('location', adventure['location'])
|
||||
if adventure.get('link'):
|
||||
event.add('url', adventure['link'])
|
||||
|
||||
organizer = vCalAddress(f'MAILTO:{user.email}')
|
||||
organizer.params['cn'] = vText(name)
|
||||
event.add('organizer', organizer)
|
||||
|
||||
cal.add_component(event)
|
||||
|
||||
response = HttpResponse(cal.to_ical(), content_type='text/calendar')
|
||||
response['Content-Disposition'] = 'attachment; filename=adventures.ics'
|
||||
return response
|
84
backend/server/adventures/views/lodging_view.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from adventures.models import Lodging
|
||||
from adventures.serializers import LodgingSerializer
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
class LodgingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Lodging.objects.all()
|
||||
serializer_class = LodgingSerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
queryset = Lodging.objects.filter(
|
||||
Q(user_id=request.user.id)
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if self.action == 'retrieve':
|
||||
# For individual adventure retrieval, include public adventures, user's own adventures and shared adventures
|
||||
return Lodging.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id)
|
||||
).distinct().order_by('-updated_at')
|
||||
# For other actions, include user's own adventures and shared adventures
|
||||
return Lodging.objects.filter(
|
||||
Q(user_id=user.id) | Q(collection__shared_with=user.id)
|
||||
).distinct().order_by('-updated_at')
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
user = request.user
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
if new_collection is not None and new_collection != instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||
def perform_create(self, serializer):
|
||||
# Retrieve the collection from the validated data
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
# Check if a collection is provided
|
||||
if collection:
|
||||
user = self.request.user
|
||||
# Check if the user is the owner or is in the shared_with list
|
||||
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||
# Return an error response if the user does not have permission
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
# if collection the owner of the adventure is the owner of the collection
|
||||
serializer.save(user_id=collection.user_id)
|
||||
return
|
||||
|
||||
# Save the adventure with the current user as the owner
|
||||
serializer.save(user_id=self.request.user)
|
130
backend/server/adventures/views/note_view.py
Normal file
|
@ -0,0 +1,130 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from adventures.models import Note
|
||||
from adventures.serializers import NoteSerializer
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from rest_framework.decorators import action
|
||||
|
||||
class NoteViewSet(viewsets.ModelViewSet):
|
||||
queryset = Note.objects.all()
|
||||
serializer_class = NoteSerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
filterset_fields = ['is_public', 'collection']
|
||||
|
||||
# return error message if user is not authenticated on the root endpoint
|
||||
def list(self, request, *args, **kwargs):
|
||||
# Prevent listing all adventures
|
||||
return Response({"detail": "Listing all notes is not allowed."},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def all(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
queryset = Note.objects.filter(
|
||||
Q(user_id=request.user.id)
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
# if the user is not authenticated return only public transportations for retrieve action
|
||||
if not self.request.user.is_authenticated:
|
||||
if self.action == 'retrieve':
|
||||
return Note.objects.filter(is_public=True).distinct().order_by('-updated_at')
|
||||
return Note.objects.none()
|
||||
|
||||
|
||||
if self.action == 'retrieve':
|
||||
# For individual adventure retrieval, include public adventures
|
||||
return Note.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||
).distinct().order_by('-updated_at')
|
||||
else:
|
||||
# For other actions, include user's own adventures and shared adventures
|
||||
return Note.objects.filter(
|
||||
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||
).distinct().order_by('-updated_at')
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
user = request.user
|
||||
print(new_collection)
|
||||
|
||||
if new_collection is not None and new_collection!=instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
user = request.user
|
||||
print(new_collection)
|
||||
|
||||
if new_collection is not None and new_collection!=instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||
def perform_create(self, serializer):
|
||||
# Retrieve the collection from the validated data
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
# Check if a collection is provided
|
||||
if collection:
|
||||
user = self.request.user
|
||||
# Check if the user is the owner or is in the shared_with list
|
||||
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||
# Return an error response if the user does not have permission
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
# if collection the owner of the adventure is the owner of the collection
|
||||
serializer.save(user_id=collection.user_id)
|
||||
return
|
||||
|
||||
# Save the adventure with the current user as the owner
|
||||
serializer.save(user_id=self.request.user)
|
183
backend/server/adventures/views/overpass_view.py
Normal file
|
@ -0,0 +1,183 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
import requests
|
||||
|
||||
class OverpassViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
BASE_URL = "https://overpass-api.de/api/interpreter"
|
||||
HEADERS = {'User-Agent': 'AdventureLog Server'}
|
||||
|
||||
def make_overpass_query(self, query):
|
||||
"""
|
||||
Sends a query to the Overpass API and returns the response data.
|
||||
Args:
|
||||
query (str): The Overpass QL query string.
|
||||
Returns:
|
||||
dict: Parsed JSON response from the Overpass API.
|
||||
Raises:
|
||||
Response: DRF Response object with an error message in case of failure.
|
||||
"""
|
||||
url = f"{self.BASE_URL}?data={query}"
|
||||
try:
|
||||
response = requests.get(url, headers=self.HEADERS)
|
||||
response.raise_for_status() # Raise an exception for HTTP errors
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException:
|
||||
return Response({"error": "Failed to connect to Overpass API"}, status=500)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return Response({"error": "Invalid response from Overpass API"}, status=400)
|
||||
|
||||
def parse_overpass_response(self, data, request):
|
||||
"""
|
||||
Parses the JSON response from the Overpass API and extracts relevant data,
|
||||
turning it into an adventure-structured object.
|
||||
|
||||
Args:
|
||||
response (dict): The JSON response from the Overpass API.
|
||||
|
||||
Returns:
|
||||
list: A list of adventure objects with structured data.
|
||||
"""
|
||||
# Extract elements (nodes/ways/relations) from the response
|
||||
nodes = data.get('elements', [])
|
||||
adventures = []
|
||||
|
||||
# include all entries, even the ones that do not have lat long
|
||||
all = request.query_params.get('all', False)
|
||||
|
||||
for node in nodes:
|
||||
# Ensure we are working with a "node" type (can also handle "way" or "relation" if needed)
|
||||
if node.get('type') not in ['node', 'way', 'relation']:
|
||||
continue
|
||||
|
||||
# Extract tags and general data
|
||||
tags = node.get('tags', {})
|
||||
adventure = {
|
||||
"id": node.get('id'), # Include the unique OSM ID
|
||||
"type": node.get('type'), # Type of element (node, way, relation)
|
||||
"name": tags.get('name', tags.get('official_name', '')), # Fallback to 'official_name'
|
||||
"description": tags.get('description', None), # Additional descriptive information
|
||||
"latitude": node.get('lat', None), # Use None for consistency with missing values
|
||||
"longitude": node.get('lon', None),
|
||||
"address": {
|
||||
"city": tags.get('addr:city', None),
|
||||
"housenumber": tags.get('addr:housenumber', None),
|
||||
"postcode": tags.get('addr:postcode', None),
|
||||
"state": tags.get('addr:state', None),
|
||||
"street": tags.get('addr:street', None),
|
||||
"country": tags.get('addr:country', None), # Add 'country' if available
|
||||
"suburb": tags.get('addr:suburb', None), # Add 'suburb' for more granularity
|
||||
},
|
||||
"feature_id": tags.get('gnis:feature_id', None),
|
||||
"tag": next((tags.get(key, None) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None),
|
||||
"contact": {
|
||||
"phone": tags.get('phone', None),
|
||||
"email": tags.get('contact:email', None),
|
||||
"website": tags.get('website', None),
|
||||
"facebook": tags.get('contact:facebook', None), # Social media links
|
||||
"twitter": tags.get('contact:twitter', None),
|
||||
},
|
||||
# "tags": tags, # Include all raw tags for future use
|
||||
}
|
||||
|
||||
# Filter out adventures with no name, latitude, or longitude
|
||||
if (adventure["name"] and
|
||||
adventure["latitude"] is not None and -90 <= adventure["latitude"] <= 90 and
|
||||
adventure["longitude"] is not None and -180 <= adventure["longitude"] <= 180) or all:
|
||||
adventures.append(adventure)
|
||||
|
||||
return adventures
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def query(self, request):
|
||||
"""
|
||||
Radius-based search for tourism-related locations around given coordinates.
|
||||
"""
|
||||
lat = request.query_params.get('lat')
|
||||
lon = request.query_params.get('lon')
|
||||
radius = request.query_params.get('radius', '1000') # Default radius: 1000 meters
|
||||
|
||||
valid_categories = ['lodging', 'food', 'tourism']
|
||||
category = request.query_params.get('category', 'all')
|
||||
if category not in valid_categories:
|
||||
return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400)
|
||||
|
||||
if category == 'tourism':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["tourism"];
|
||||
node(around:{radius},{lat},{lon})["leisure"];
|
||||
node(around:{radius},{lat},{lon})["historic"];
|
||||
node(around:{radius},{lat},{lon})["sport"];
|
||||
node(around:{radius},{lat},{lon})["natural"];
|
||||
node(around:{radius},{lat},{lon})["attraction"];
|
||||
node(around:{radius},{lat},{lon})["museum"];
|
||||
node(around:{radius},{lat},{lon})["zoo"];
|
||||
node(around:{radius},{lat},{lon})["aquarium"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
if category == 'lodging':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["tourism"="hotel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="motel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="guest_house"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="hostel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="camp_site"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="caravan_site"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="chalet"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="alpine_hut"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="apartment"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
if category == 'food':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["amenity"="restaurant"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="cafe"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="fast_food"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="pub"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="bar"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="food_court"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="ice_cream"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="bakery"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="confectionery"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
|
||||
# Validate required parameters
|
||||
if not lat or not lon:
|
||||
return Response(
|
||||
{"error": "Latitude and longitude parameters are required."}, status=400
|
||||
)
|
||||
|
||||
data = self.make_overpass_query(query)
|
||||
adventures = self.parse_overpass_response(data, request)
|
||||
return Response(adventures)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='search')
|
||||
def search(self, request):
|
||||
"""
|
||||
Name-based search for nodes with the specified name.
|
||||
"""
|
||||
name = request.query_params.get('name')
|
||||
|
||||
# Validate required parameter
|
||||
if not name:
|
||||
return Response({"error": "Name parameter is required."}, status=400)
|
||||
|
||||
# Construct Overpass API query
|
||||
query = f'[out:json];node["name"~"{name}",i];out;'
|
||||
data = self.make_overpass_query(query)
|
||||
|
||||
adventures = self.parse_overpass_response(data, request)
|
||||
return Response(adventures)
|
121
backend/server/adventures/views/reverse_geocode_view.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
|
||||
from adventures.models import Adventure
|
||||
from adventures.serializers import AdventureSerializer
|
||||
import requests
|
||||
|
||||
class ReverseGeocodeViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def extractIsoCode(self, data):
|
||||
"""
|
||||
Extract the ISO code from the response data.
|
||||
Returns a dictionary containing the region name, country name, and ISO code if found.
|
||||
"""
|
||||
iso_code = None
|
||||
town_city_or_county = None
|
||||
display_name = None
|
||||
country_code = None
|
||||
city = None
|
||||
visited_city = None
|
||||
location_name = None
|
||||
|
||||
# town = None
|
||||
# city = None
|
||||
# county = None
|
||||
|
||||
if 'name' in data.keys():
|
||||
location_name = data['name']
|
||||
|
||||
if 'address' in data.keys():
|
||||
keys = data['address'].keys()
|
||||
for key in keys:
|
||||
if key.find("ISO") != -1:
|
||||
iso_code = data['address'][key]
|
||||
if 'town' in keys:
|
||||
town_city_or_county = data['address']['town']
|
||||
if 'county' in keys:
|
||||
town_city_or_county = data['address']['county']
|
||||
if 'city' in keys:
|
||||
town_city_or_county = data['address']['city']
|
||||
if not iso_code:
|
||||
return {"error": "No region found"}
|
||||
|
||||
region = Region.objects.filter(id=iso_code).first()
|
||||
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||
|
||||
region_visited = False
|
||||
city_visited = False
|
||||
country_code = iso_code[:2]
|
||||
|
||||
if region:
|
||||
if town_city_or_county:
|
||||
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
|
||||
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||
|
||||
if visited_region:
|
||||
region_visited = True
|
||||
if visited_city:
|
||||
city_visited = True
|
||||
if region:
|
||||
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
|
||||
return {"error": "No region found"}
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def reverse_geocode(self, request):
|
||||
lat = request.query_params.get('lat', '')
|
||||
lon = request.query_params.get('lon', '')
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||
headers = {'User-Agent': 'AdventureLog Server'}
|
||||
response = requests.get(url, headers=headers)
|
||||
try:
|
||||
data = response.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return Response({"error": "Invalid response from geocoding service"}, status=400)
|
||||
return Response(self.extractIsoCode(data))
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def mark_visited_region(self, request):
|
||||
# searches through all of the users adventures, if the serialized data is_visited, is true, runs reverse geocode on the adventures and if a region is found, marks it as visited. Use the extractIsoCode function to get the region
|
||||
new_region_count = 0
|
||||
new_regions = {}
|
||||
new_city_count = 0
|
||||
new_cities = {}
|
||||
adventures = Adventure.objects.filter(user_id=self.request.user)
|
||||
serializer = AdventureSerializer(adventures, many=True)
|
||||
for adventure, serialized_adventure in zip(adventures, serializer.data):
|
||||
if serialized_adventure['is_visited'] == True:
|
||||
lat = adventure.latitude
|
||||
lon = adventure.longitude
|
||||
if not lat or not lon:
|
||||
continue
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||
headers = {'User-Agent': 'AdventureLog Server'}
|
||||
response = requests.get(url, headers=headers)
|
||||
try:
|
||||
data = response.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return Response({"error": "Invalid response from geocoding service"}, status=400)
|
||||
extracted_region = self.extractIsoCode(data)
|
||||
if 'error' not in extracted_region:
|
||||
region = Region.objects.filter(id=extracted_region['region_id']).first()
|
||||
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||
if not visited_region:
|
||||
visited_region = VisitedRegion(region=region, user_id=self.request.user)
|
||||
visited_region.save()
|
||||
new_region_count += 1
|
||||
new_regions[region.id] = region.name
|
||||
|
||||
if extracted_region['city_id'] is not None:
|
||||
city = City.objects.filter(id=extracted_region['city_id']).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||
if not visited_city:
|
||||
visited_city = VisitedCity(city=city, user_id=self.request.user)
|
||||
visited_city.save()
|
||||
new_city_count += 1
|
||||
new_cities[city.id] = city.name
|
||||
return Response({"new_regions": new_region_count, "regions": new_regions, "new_cities": new_city_count, "cities": new_cities})
|
51
backend/server/adventures/views/stats_view.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.shortcuts import get_object_or_404
|
||||
from worldtravel.models import City, Region, Country, VisitedCity, VisitedRegion
|
||||
from adventures.models import Adventure, Collection
|
||||
from users.serializers import CustomUserDetailsSerializer as PublicUserSerializer
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class StatsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
A simple ViewSet for listing the stats of a user.
|
||||
"""
|
||||
@action(detail=False, methods=['get'], url_path='counts/(?P<username>[\w.@+-]+)')
|
||||
def counts(self, request, username):
|
||||
if request.user.username == username:
|
||||
user = get_object_or_404(User, username=username)
|
||||
else:
|
||||
user = get_object_or_404(User, username=username, public_profile=True)
|
||||
# serializer = PublicUserSerializer(user)
|
||||
|
||||
# remove the email address from the response
|
||||
user.email = None
|
||||
|
||||
# get the counts for the user
|
||||
adventure_count = Adventure.objects.filter(
|
||||
user_id=user.id).count()
|
||||
trips_count = Collection.objects.filter(
|
||||
user_id=user.id).count()
|
||||
visited_city_count = VisitedCity.objects.filter(
|
||||
user_id=user.id).count()
|
||||
total_cities = City.objects.count()
|
||||
visited_region_count = VisitedRegion.objects.filter(
|
||||
user_id=user.id).count()
|
||||
total_regions = Region.objects.count()
|
||||
visited_country_count = VisitedRegion.objects.filter(
|
||||
user_id=user.id).values('region__country').distinct().count()
|
||||
total_countries = Country.objects.count()
|
||||
return Response({
|
||||
'adventure_count': adventure_count,
|
||||
'trips_count': trips_count,
|
||||
'visited_city_count': visited_city_count,
|
||||
'total_cities': total_cities,
|
||||
'visited_region_count': visited_region_count,
|
||||
'total_regions': total_regions,
|
||||
'visited_country_count': visited_country_count,
|
||||
'total_countries': total_countries
|
||||
})
|
84
backend/server/adventures/views/transportation_view.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from adventures.models import Transportation
|
||||
from adventures.serializers import TransportationSerializer
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
class TransportationViewSet(viewsets.ModelViewSet):
|
||||
queryset = Transportation.objects.all()
|
||||
serializer_class = TransportationSerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
queryset = Transportation.objects.filter(
|
||||
Q(user_id=request.user.id)
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if self.action == 'retrieve':
|
||||
# For individual adventure retrieval, include public adventures, user's own adventures and shared adventures
|
||||
return Transportation.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id)
|
||||
).distinct().order_by('-updated_at')
|
||||
# For other actions, include user's own adventures and shared adventures
|
||||
return Transportation.objects.filter(
|
||||
Q(user_id=user.id) | Q(collection__shared_with=user.id)
|
||||
).distinct().order_by('-updated_at')
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
user = request.user
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
if new_collection is not None and new_collection != instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||
def perform_create(self, serializer):
|
||||
# Retrieve the collection from the validated data
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
# Check if a collection is provided
|
||||
if collection:
|
||||
user = self.request.user
|
||||
# Check if the user is the owner or is in the shared_with list
|
||||
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||
# Return an error response if the user does not have permission
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
# if collection the owner of the adventure is the owner of the collection
|
||||
serializer.save(user_id=collection.user_id)
|
||||
return
|
||||
|
||||
# Save the adventure with the current user as the owner
|
||||
serializer.save(user_id=self.request.user)
|
0
backend/server/integrations/__init__.py
Normal file
9
backend/server/integrations/admin.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.contrib import admin
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
from .models import ImmichIntegration
|
||||
|
||||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
admin.site.register(ImmichIntegration)
|
6
backend/server/integrations/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'integrations'
|
27
backend/server/integrations/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-02 23:16
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ImmichIntegration',
|
||||
fields=[
|
||||
('server_url', models.CharField(max_length=255)),
|
||||
('api_key', models.CharField(max_length=255)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
0
backend/server/integrations/migrations/__init__.py
Normal file
15
backend/server/integrations/models.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class ImmichIntegration(models.Model):
|
||||
server_url = models.CharField(max_length=255)
|
||||
api_key = models.CharField(max_length=255)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE)
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username + ' - ' + self.server_url
|
13
backend/server/integrations/serializers.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from .models import ImmichIntegration
|
||||
from rest_framework import serializers
|
||||
|
||||
class ImmichIntegrationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ImmichIntegration
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'user']
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
representation.pop('user', None)
|
||||
return representation
|
3
backend/server/integrations/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
14
backend/server/integrations/urls.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet
|
||||
|
||||
# Create the router and register the ViewSet
|
||||
router = DefaultRouter()
|
||||
router.register(r'immich', ImmichIntegrationView, basename='immich')
|
||||
router.register(r'', IntegrationView, basename='integrations')
|
||||
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
|
||||
|
||||
# Include the router URLs
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)), # Includes /immich/ routes
|
||||
]
|
324
backend/server/integrations/views.py
Normal file
|
@ -0,0 +1,324 @@
|
|||
import os
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import viewsets, status
|
||||
|
||||
from .serializers import ImmichIntegrationSerializer
|
||||
from .models import ImmichIntegration
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
import requests
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
class IntegrationView(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
def list(self, request):
|
||||
"""
|
||||
RESTful GET method for listing all integrations.
|
||||
"""
|
||||
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'immich': immich_integrations.exists()
|
||||
},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 25
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 1000
|
||||
|
||||
class ImmichIntegrationView(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
def check_integration(self, request):
|
||||
"""
|
||||
Checks if the user has an active Immich integration.
|
||||
Returns:
|
||||
- None if the integration exists.
|
||||
- A Response with an error message if the integration is missing.
|
||||
"""
|
||||
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
if not user_integrations.exists():
|
||||
return Response(
|
||||
{
|
||||
'message': 'You need to have an active Immich integration to use this feature.',
|
||||
'error': True,
|
||||
'code': 'immich.integration_missing'
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
return ImmichIntegration.objects.first()
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='search')
|
||||
def search(self, request):
|
||||
"""
|
||||
Handles the logic for searching Immich images.
|
||||
"""
|
||||
# Check for integration before proceeding
|
||||
integration = self.check_integration(request)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
query = request.query_params.get('query', '')
|
||||
date = request.query_params.get('date', '')
|
||||
|
||||
if not query and not date:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Query or date is required.',
|
||||
'error': True,
|
||||
'code': 'immich.query_required'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
arguments = {}
|
||||
if query:
|
||||
arguments['query'] = query
|
||||
if date:
|
||||
arguments['takenBefore'] = date
|
||||
|
||||
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
|
||||
try:
|
||||
url = f'{integration.server_url}/search/{"smart" if query else "metadata"}'
|
||||
immich_fetch = requests.post(url, headers={
|
||||
'x-api-key': integration.api_key
|
||||
},
|
||||
json = arguments
|
||||
)
|
||||
res = immich_fetch.json()
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response(
|
||||
{
|
||||
'message': 'The Immich server is currently down or unreachable.',
|
||||
'error': True,
|
||||
'code': 'immich.server_down'
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
if 'assets' in res and 'items' in res['assets']:
|
||||
paginator = self.pagination_class()
|
||||
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
public_url = public_url.replace("'", "")
|
||||
for item in res['assets']['items']:
|
||||
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}'
|
||||
result_page = paginator.paginate_queryset(res['assets']['items'], request)
|
||||
return paginator.get_paginated_response(result_page)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
'message': 'No items found.',
|
||||
'error': True,
|
||||
'code': 'immich.no_items_found'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)')
|
||||
def get(self, request, imageid=None):
|
||||
"""
|
||||
RESTful GET method for retrieving a specific Immich image by ID.
|
||||
"""
|
||||
# Check for integration before proceeding
|
||||
integration = self.check_integration(request)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
if not imageid:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Image ID is required.',
|
||||
'error': True,
|
||||
'code': 'immich.imageid_required'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
|
||||
try:
|
||||
immich_fetch = requests.get(f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', headers={
|
||||
'x-api-key': integration.api_key
|
||||
})
|
||||
# should return the image file
|
||||
from django.http import HttpResponse
|
||||
return HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK)
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response(
|
||||
{
|
||||
'message': 'The Immich server is currently down or unreachable.',
|
||||
'error': True,
|
||||
'code': 'immich.server_down'
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def albums(self, request):
|
||||
"""
|
||||
RESTful GET method for retrieving all Immich albums.
|
||||
"""
|
||||
# Check for integration before proceeding
|
||||
integration = self.check_integration(request)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
|
||||
try:
|
||||
immich_fetch = requests.get(f'{integration.server_url}/albums', headers={
|
||||
'x-api-key': integration.api_key
|
||||
})
|
||||
res = immich_fetch.json()
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response(
|
||||
{
|
||||
'message': 'The Immich server is currently down or unreachable.',
|
||||
'error': True,
|
||||
'code': 'immich.server_down'
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
return Response(
|
||||
res,
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='albums/(?P<albumid>[^/.]+)')
|
||||
def album(self, request, albumid=None):
|
||||
"""
|
||||
RESTful GET method for retrieving a specific Immich album by ID.
|
||||
"""
|
||||
# Check for integration before proceeding
|
||||
integration = self.check_integration(request)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
if not albumid:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Album ID is required.',
|
||||
'error': True,
|
||||
'code': 'immich.albumid_required'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
|
||||
try:
|
||||
immich_fetch = requests.get(f'{integration.server_url}/albums/{albumid}', headers={
|
||||
'x-api-key': integration.api_key
|
||||
})
|
||||
res = immich_fetch.json()
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response(
|
||||
{
|
||||
'message': 'The Immich server is currently down or unreachable.',
|
||||
'error': True,
|
||||
'code': 'immich.server_down'
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
if 'assets' in res:
|
||||
paginator = self.pagination_class()
|
||||
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
public_url = public_url.replace("'", "")
|
||||
for item in res['assets']:
|
||||
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}'
|
||||
result_page = paginator.paginate_queryset(res['assets'], request)
|
||||
return paginator.get_paginated_response(result_page)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
'message': 'No assets found in this album.',
|
||||
'error': True,
|
||||
'code': 'immich.no_assets_found'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
class ImmichIntegrationViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ImmichIntegrationSerializer
|
||||
queryset = ImmichIntegration.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
return ImmichIntegration.objects.filter(user=self.request.user)
|
||||
|
||||
def create(self, request):
|
||||
"""
|
||||
RESTful POST method for creating a new Immich integration.
|
||||
"""
|
||||
|
||||
# Check if the user already has an integration
|
||||
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
if user_integrations.exists():
|
||||
return Response(
|
||||
{
|
||||
'message': 'You already have an active Immich integration.',
|
||||
'error': True,
|
||||
'code': 'immich.integration_exists'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
"""
|
||||
RESTful DELETE method for deleting an existing Immich integration.
|
||||
"""
|
||||
integration = ImmichIntegration.objects.filter(user=request.user, id=pk).first()
|
||||
if not integration:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Integration not found.',
|
||||
'error': True,
|
||||
'code': 'immich.integration_not_found'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
integration.delete()
|
||||
return Response(
|
||||
{
|
||||
'message': 'Integration deleted successfully.'
|
||||
},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
# If the user has an integration, we only want to return that integration
|
||||
|
||||
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
if user_integrations.exists():
|
||||
integration = user_integrations.first()
|
||||
serializer = self.serializer_class(integration)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
'message': 'No integration found.',
|
||||
'error': True,
|
||||
'code': 'immich.integration_not_found'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
|
@ -13,6 +13,8 @@ import os
|
|||
from dotenv import load_dotenv
|
||||
from os import getenv
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from publicsuffix2 import get_sld
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
@ -42,6 +44,7 @@ INSTALLED_APPS = (
|
|||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
# "allauth_ui",
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'allauth',
|
||||
|
@ -49,21 +52,29 @@ INSTALLED_APPS = (
|
|||
'allauth.mfa',
|
||||
'allauth.headless',
|
||||
'allauth.socialaccount',
|
||||
# "widget_tweaks",
|
||||
# "slippers",
|
||||
'allauth.socialaccount.providers.github',
|
||||
'allauth.socialaccount.providers.openid_connect',
|
||||
'drf_yasg',
|
||||
'corsheaders',
|
||||
'adventures',
|
||||
'worldtravel',
|
||||
'users',
|
||||
'integrations',
|
||||
'django.contrib.gis',
|
||||
# 'achievements', # Not done yet, will be added later in a future update
|
||||
# 'widget_tweaks',
|
||||
# 'slippers',
|
||||
|
||||
)
|
||||
|
||||
MIDDLEWARE = (
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'adventures.middleware.XSessionTokenMiddleware',
|
||||
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'adventures.middleware.OverrideHostMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
|
@ -119,18 +130,46 @@ USE_L10N = True
|
|||
|
||||
USE_TZ = True
|
||||
|
||||
unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000')
|
||||
FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"'))
|
||||
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
SESSION_COOKIE_NAME = 'sessionid'
|
||||
|
||||
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
|
||||
|
||||
hostname = urlparse(FRONTEND_URL).hostname
|
||||
is_ip_address = hostname.replace('.', '').isdigit()
|
||||
|
||||
# Check if the hostname is single-label (no dots)
|
||||
is_single_label = '.' not in hostname
|
||||
|
||||
if is_ip_address or is_single_label:
|
||||
# Do not set a domain for IP addresses or single-label hostnames
|
||||
SESSION_COOKIE_DOMAIN = None
|
||||
else:
|
||||
# Use publicsuffix2 to calculate the correct cookie domain
|
||||
cookie_domain = get_sld(hostname)
|
||||
if cookie_domain:
|
||||
SESSION_COOKIE_DOMAIN = f".{cookie_domain}"
|
||||
else:
|
||||
# Fallback to the hostname if parsing fails
|
||||
SESSION_COOKIE_DOMAIN = hostname
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.7/howto/static-files/
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
MEDIA_ROOT = BASE_DIR / 'media' # This path must match the NGINX root
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
|
||||
STORAGES = {
|
||||
|
@ -142,6 +181,7 @@ STORAGES = {
|
|||
}
|
||||
}
|
||||
|
||||
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
|
@ -164,9 +204,6 @@ TEMPLATES = [
|
|||
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True'
|
||||
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
|
||||
|
||||
ALLAUTH_UI_THEME = "dark"
|
||||
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
|
||||
|
||||
AUTH_USER_MODEL = 'users.CustomUser'
|
||||
|
||||
ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter'
|
||||
|
@ -175,7 +212,10 @@ ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm'
|
|||
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
|
||||
FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000')
|
||||
# Set login redirect URL to the frontend
|
||||
LOGIN_REDIRECT_URL = FRONTEND_URL
|
||||
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||
|
||||
HEADLESS_FRONTEND_URLS = {
|
||||
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",
|
||||
|
@ -187,6 +227,10 @@ HEADLESS_FRONTEND_URLS = {
|
|||
"socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback",
|
||||
}
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'users.backends.NoPasswordAuthBackend',
|
||||
]
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
SITE_ID = 1
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
|
@ -222,10 +266,16 @@ REST_FRAMEWORK = {
|
|||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
'LOGIN_URL': 'login',
|
||||
'LOGOUT_URL': 'logout',
|
||||
}
|
||||
if DEBUG:
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||
)
|
||||
else:
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
)
|
||||
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
|
||||
|
||||
|
@ -258,5 +308,8 @@ LOGGING = {
|
|||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
|
||||
|
||||
# https://github.com/dr5hn/countries-states-cities-database/tags
|
||||
COUNTRY_REGION_JSON_VERSION = 'v2.4'
|
||||
COUNTRY_REGION_JSON_VERSION = 'v2.5'
|
|
@ -1,12 +1,9 @@
|
|||
from django.urls import include, re_path, path
|
||||
from django.contrib import admin
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView
|
||||
from .views import get_csrf_token
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView
|
||||
from .views import get_csrf_token, get_public_url, serve_protected_media
|
||||
from drf_yasg.views import get_schema_view
|
||||
|
||||
from drf_yasg import openapi
|
||||
|
||||
schema_view = get_schema_view(
|
||||
|
@ -18,16 +15,24 @@ schema_view = get_schema_view(
|
|||
urlpatterns = [
|
||||
path('api/', include('adventures.urls')),
|
||||
path('api/', include('worldtravel.urls')),
|
||||
path("_allauth/", include("allauth.headless.urls")),
|
||||
path("auth/", include("allauth.headless.urls")),
|
||||
|
||||
# Serve protected media files
|
||||
re_path(r'^media/(?P<path>.*)$', serve_protected_media, name='serve-protected-media'),
|
||||
|
||||
path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'),
|
||||
path('auth/users/', PublicUserListView.as_view(), name='public-user-list'),
|
||||
path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
|
||||
path('auth/user/<str:username>/', PublicUserDetailView.as_view(), name='public-user-detail'),
|
||||
path('auth/update-user/', UpdateUserMetadataView.as_view(), name='update-user-metadata'),
|
||||
|
||||
path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'),
|
||||
|
||||
path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'),
|
||||
|
||||
path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'),
|
||||
|
||||
path('csrf/', get_csrf_token, name='get_csrf_token'),
|
||||
path('public-url/', get_public_url, name='get_public_url'),
|
||||
|
||||
path('', TemplateView.as_view(template_name='home.html')),
|
||||
|
||||
|
@ -39,6 +44,7 @@ urlpatterns = [
|
|||
# path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
|
||||
# Include the API endpoints:
|
||||
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
path("api/integrations/", include("integrations.urls")),
|
||||
|
||||
# Include the API endpoints:
|
||||
]
|
|
@ -1,6 +1,42 @@
|
|||
from django.http import JsonResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from os import getenv
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseForbidden
|
||||
from django.views.static import serve
|
||||
from adventures.utils.file_permissions import checkFilePermission
|
||||
|
||||
def get_csrf_token(request):
|
||||
csrf_token = get_token(request)
|
||||
return JsonResponse({'csrfToken': csrf_token})
|
||||
|
||||
def get_public_url(request):
|
||||
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})
|
||||
|
||||
protected_paths = ['images/', 'attachments/']
|
||||
|
||||
def serve_protected_media(request, path):
|
||||
if any([path.startswith(protected_path) for protected_path in protected_paths]):
|
||||
image_id = path.split('/')[1]
|
||||
user = request.user
|
||||
media_type = path.split('/')[0] + '/'
|
||||
if checkFilePermission(image_id, user, media_type):
|
||||
if settings.DEBUG:
|
||||
# In debug mode, serve the file directly
|
||||
return serve(request, path, document_root=settings.MEDIA_ROOT)
|
||||
else:
|
||||
# In production, use X-Accel-Redirect to serve the file using Nginx
|
||||
response = HttpResponse()
|
||||
response['Content-Type'] = ''
|
||||
response['X-Accel-Redirect'] = '/protectedMedia/' + path
|
||||
return response
|
||||
else:
|
||||
return HttpResponseForbidden()
|
||||
else:
|
||||
if settings.DEBUG:
|
||||
return serve(request, path, document_root=settings.MEDIA_ROOT)
|
||||
else:
|
||||
response = HttpResponse()
|
||||
response['Content-Type'] = ''
|
||||
response['X-Accel-Redirect'] = '/protectedMedia/' + path
|
||||
return response
|
|
@ -1,4 +1,4 @@
|
|||
Django==5.0.8
|
||||
Django==5.0.11
|
||||
djangorestframework>=3.15.2
|
||||
django-allauth==0.63.3
|
||||
drf-yasg==1.21.4
|
||||
|
@ -13,8 +13,12 @@ django-geojson
|
|||
setuptools
|
||||
gunicorn==23.0.0
|
||||
qrcode==8.0
|
||||
# slippers==0.6.2
|
||||
# django-allauth-ui==1.5.1
|
||||
# django-widget-tweaks==1.5.0
|
||||
slippers==0.6.2
|
||||
django-allauth-ui==1.5.1
|
||||
django-widget-tweaks==1.5.0
|
||||
django-ical==1.9.2
|
||||
icalendar==6.1.0
|
||||
icalendar==6.1.0
|
||||
ijson==3.3.0
|
||||
tqdm==4.67.1
|
||||
overpy==0.7
|
||||
publicsuffix2==2.20191221
|
|
@ -53,6 +53,7 @@
|
|||
>Documentation</a
|
||||
>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
|
@ -60,6 +61,7 @@
|
|||
>Source Code</a
|
||||
>
|
||||
</li>
|
||||
<li><a href="/docs">API Docs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--/.nav-collapse -->
|
||||
|
|
16
backend/server/users/backends.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from django.contrib.auth.backends import ModelBackend
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
|
||||
class NoPasswordAuthBackend(ModelBackend):
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
print("NoPasswordAuthBackend")
|
||||
# First, attempt normal authentication
|
||||
user = super().authenticate(request, username=username, password=password, **kwargs)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if SocialAccount.objects.filter(user=user).exists() and user.disable_password:
|
||||
# If yes, disable login via password
|
||||
return None
|
||||
|
||||
return user
|
|
@ -1,17 +0,0 @@
|
|||
from django import forms
|
||||
|
||||
class CustomSignupForm(forms.Form):
|
||||
first_name = forms.CharField(max_length=30, required=True)
|
||||
last_name = forms.CharField(max_length=30, required=True)
|
||||
|
||||
def signup(self, request, user):
|
||||
# Delay the import to avoid circular import
|
||||
from allauth.account.forms import SignupForm
|
||||
|
||||
# No need to call super() from CustomSignupForm; use the SignupForm directly if needed
|
||||
user.first_name = self.cleaned_data['first_name']
|
||||
user.last_name = self.cleaned_data['last_name']
|
||||
|
||||
# Save the user instance
|
||||
user.save()
|
||||
return user
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.8 on 2025-03-17 01:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_alter_customuser_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='disable_password',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -8,6 +8,7 @@ class CustomUser(AbstractUser):
|
|||
profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/')
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
public_profile = models.BooleanField(default=False)
|
||||
disable_password = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
|
@ -36,7 +36,7 @@ import os
|
|||
|
||||
class UserDetailsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
User model w/o password
|
||||
User model without exposing the password.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
|
@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer):
|
|||
return username
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
extra_fields = ['profile_pic', 'uuid', 'public_profile']
|
||||
profile_pic = serializers.ImageField(required=False)
|
||||
|
||||
if hasattr(UserModel, 'USERNAME_FIELD'):
|
||||
extra_fields.append(UserModel.USERNAME_FIELD)
|
||||
|
@ -64,19 +64,16 @@ class UserDetailsSerializer(serializers.ModelSerializer):
|
|||
extra_fields.append('date_joined')
|
||||
if hasattr(UserModel, 'is_staff'):
|
||||
extra_fields.append('is_staff')
|
||||
if hasattr(UserModel, 'public_profile'):
|
||||
extra_fields.append('public_profile')
|
||||
if hasattr(UserModel, 'disable_password'):
|
||||
extra_fields.append('disable_password')
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
|
||||
|
||||
model = UserModel
|
||||
fields = ('pk', *extra_fields)
|
||||
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
|
||||
fields = ['pk', *extra_fields]
|
||||
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk', 'disable_password')
|
||||
|
||||
def handle_public_profile_change(self, instance, validated_data):
|
||||
"""Remove user from `shared_with` if public profile is set to False."""
|
||||
"""
|
||||
Remove user from `shared_with` if public profile is set to False.
|
||||
"""
|
||||
if 'public_profile' in validated_data and not validated_data['public_profile']:
|
||||
for collection in Collection.objects.filter(shared_with=instance):
|
||||
collection.shared_with.remove(instance)
|
||||
|
@ -91,20 +88,39 @@ class UserDetailsSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class CustomUserDetailsSerializer(UserDetailsSerializer):
|
||||
"""
|
||||
Custom serializer to add additional fields and logic for the user details.
|
||||
"""
|
||||
|
||||
has_password = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(UserDetailsSerializer.Meta):
|
||||
model = CustomUser
|
||||
fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile')
|
||||
read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',)
|
||||
fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password', 'disable_password']
|
||||
read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password', 'disable_password')
|
||||
|
||||
@staticmethod
|
||||
def get_has_password(instance):
|
||||
"""
|
||||
Computes whether the user has a usable password set.
|
||||
"""
|
||||
return instance.has_usable_password()
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Customizes the serialized output to modify `profile_pic` URL and add computed fields.
|
||||
"""
|
||||
representation = super().to_representation(instance)
|
||||
|
||||
# Construct profile picture URL if it exists
|
||||
if instance.profile_pic:
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
#print(public_url)
|
||||
# remove any ' from the url
|
||||
public_url = public_url.replace("'", "")
|
||||
public_url = public_url.replace("'", "") # Sanitize URL
|
||||
representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}"
|
||||
del representation['pk'] # remove the pk field from the response
|
||||
|
||||
# Remove `pk` field from the response
|
||||
representation.pop('pk', None)
|
||||
# Remove the email field
|
||||
representation.pop('email', None)
|
||||
|
||||
return representation
|
||||
|
|
|
@ -1,3 +1,82 @@
|
|||
from django.test import TestCase
|
||||
from rest_framework.test import APITestCase
|
||||
from .models import CustomUser
|
||||
from uuid import UUID
|
||||
|
||||
# Create your tests here.
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
class UserAPITestCase(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Signup a new user
|
||||
response = self.client.post('/auth/browser/v1/auth/signup', {
|
||||
'username': 'testuser',
|
||||
'email': 'testuser@example.com',
|
||||
'password': 'testpassword',
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User',
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_001_user(self):
|
||||
# Fetch user metadata
|
||||
response = self.client.get('/auth/user-metadata/', format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data['username'], 'testuser')
|
||||
self.assertEqual(data['email'], 'testuser@example.com')
|
||||
self.assertEqual(data['first_name'], 'Test')
|
||||
self.assertEqual(data['last_name'], 'User')
|
||||
self.assertEqual(data['public_profile'], False)
|
||||
self.assertEqual(data['profile_pic'], None)
|
||||
self.assertEqual(UUID(data['uuid']), CustomUser.objects.get(username='testuser').uuid)
|
||||
self.assertEqual(data['is_staff'], False)
|
||||
self.assertEqual(data['has_password'], True)
|
||||
|
||||
def test_002_user_update(self):
|
||||
try:
|
||||
userModel = CustomUser.objects.get(username='testuser2')
|
||||
except:
|
||||
userModel = None
|
||||
|
||||
self.assertEqual(userModel, None)
|
||||
# Update user metadata
|
||||
response = self.client.patch('/auth/update-user/', {
|
||||
'username': 'testuser2',
|
||||
'first_name': 'Test2',
|
||||
'last_name': 'User2',
|
||||
'public_profile': True,
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
# Note that the email field is not updated because that is a seperate endpoint
|
||||
userModel = CustomUser.objects.get(username='testuser2')
|
||||
self.assertEqual(data['username'], 'testuser2')
|
||||
self.assertEqual(data['email'], 'testuser@example.com')
|
||||
self.assertEqual(data['first_name'], 'Test2')
|
||||
self.assertEqual(data['last_name'], 'User2')
|
||||
self.assertEqual(data['public_profile'], True)
|
||||
self.assertEqual(data['profile_pic'], None)
|
||||
self.assertEqual(UUID(data['uuid']), CustomUser.objects.get(username='testuser2').uuid)
|
||||
self.assertEqual(data['is_staff'], False)
|
||||
self.assertEqual(data['has_password'], True)
|
||||
|
||||
def test_003_user_add_email(self):
|
||||
# Update user email
|
||||
response = self.client.post('/auth/browser/v1/account/email', {
|
||||
'email': 'testuser2@example.com',
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
email_data = data['data'][0]
|
||||
|
||||
self.assertEqual(email_data['email'], 'testuser2@example.com')
|
||||
self.assertEqual(email_data['primary'], False)
|
||||
self.assertEqual(email_data['verified'], False)
|
||||
|
||||
emails = EmailAddress.objects.filter(user=CustomUser.objects.get(username='testuser'))
|
||||
self.assertEqual(emails.count(), 2)
|
||||
# assert email are testuser@example and testuser2@example.com
|
||||
self.assertEqual(emails[1].email, 'testuser@example.com')
|
||||
self.assertEqual(emails[0].email, 'testuser2@example.com')
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from os import getenv
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
@ -9,6 +10,10 @@ from django.conf import settings
|
|||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib.auth import get_user_model
|
||||
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from adventures.serializers import AdventureSerializer, CollectionSerializer
|
||||
from adventures.models import Adventure, Collection
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -64,6 +69,10 @@ class PublicUserListView(APIView):
|
|||
for user in users:
|
||||
user.email = None
|
||||
serializer = PublicUserSerializer(users, many=True)
|
||||
# for every user, remove the field has_password
|
||||
for user in serializer.data:
|
||||
user.pop('has_password', None)
|
||||
user.pop('disable_password', None)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
class PublicUserDetailView(APIView):
|
||||
|
@ -77,12 +86,29 @@ class PublicUserDetailView(APIView):
|
|||
},
|
||||
operation_description="Get public user information."
|
||||
)
|
||||
def get(self, request, user_id):
|
||||
user = get_object_or_404(User, uuid=user_id, public_profile=True)
|
||||
def get(self, request, username):
|
||||
if request.user.username == username:
|
||||
user = get_object_or_404(User, username=username)
|
||||
else:
|
||||
user = get_object_or_404(User, username=username, public_profile=True)
|
||||
serializer = PublicUserSerializer(user)
|
||||
# for every user, remove the field has_password
|
||||
serializer.data.pop('has_password', None)
|
||||
|
||||
# remove the email address from the response
|
||||
user.email = None
|
||||
serializer = PublicUserSerializer(user)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Get the users adventures and collections to include in the response
|
||||
adventures = Adventure.objects.filter(user_id=user, is_public=True)
|
||||
collections = Collection.objects.filter(user_id=user, is_public=True)
|
||||
adventure_serializer = AdventureSerializer(adventures, many=True)
|
||||
collection_serializer = CollectionSerializer(collections, many=True)
|
||||
|
||||
return Response({
|
||||
'user': serializer.data,
|
||||
'adventures': adventure_serializer.data,
|
||||
'collections': collection_serializer.data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
class UserMetadataView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
@ -120,4 +146,62 @@ class UpdateUserMetadataView(APIView):
|
|||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class EnabledSocialProvidersView(APIView):
|
||||
"""
|
||||
Get enabled social providers for social authentication. This is used to determine which buttons to show on the frontend. Also returns a URL for each to start the authentication flow.
|
||||
"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={
|
||||
200: openapi.Response('Enabled social providers'),
|
||||
400: 'Bad Request'
|
||||
},
|
||||
operation_description="Get enabled social providers."
|
||||
)
|
||||
def get(self, request):
|
||||
social_providers = SocialApp.objects.filter(sites=settings.SITE_ID)
|
||||
providers = []
|
||||
for provider in social_providers:
|
||||
if provider.provider == 'openid_connect':
|
||||
new_provider = f'oidc/{provider.client_id}'
|
||||
else:
|
||||
new_provider = provider.provider
|
||||
providers.append({
|
||||
'provider': provider.provider,
|
||||
'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/",
|
||||
'name': provider.name
|
||||
})
|
||||
return Response(providers, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DisablePasswordAuthenticationView(APIView):
|
||||
"""
|
||||
Disable password authentication for a user. This is used when a user signs up with a social provider.
|
||||
"""
|
||||
|
||||
# Allows the user to set the disable_password field to True if they have a social account linked
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={
|
||||
200: openapi.Response('Password authentication disabled'),
|
||||
400: 'Bad Request'
|
||||
},
|
||||
operation_description="Disable password authentication."
|
||||
)
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
if SocialAccount.objects.filter(user=user).exists():
|
||||
user.disable_password = True
|
||||
user.save()
|
||||
return Response({"detail": "Password authentication disabled."}, status=status.HTTP_200_OK)
|
||||
return Response({"detail": "No social account linked."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request):
|
||||
user = request.user
|
||||
user.disable_password = False
|
||||
user.save()
|
||||
return Response({"detail": "Password authentication enabled."}, status=status.HTTP_200_OK)
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
import requests
|
||||
from worldtravel.models import Country, Region
|
||||
from worldtravel.models import Country, Region, City
|
||||
from django.db import transaction
|
||||
import json
|
||||
from tqdm import tqdm
|
||||
import ijson
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
@ -37,37 +38,60 @@ def saveCountryFlag(country_code):
|
|||
class Command(BaseCommand):
|
||||
help = 'Imports the world travel data'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json')
|
||||
if not os.path.exists(countries_json_path):
|
||||
res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/countries%2Bstates.json')
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file')
|
||||
|
||||
def handle(self, **options):
|
||||
force = options['force']
|
||||
batch_size = 100
|
||||
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json')
|
||||
if not os.path.exists(countries_json_path) or force:
|
||||
res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json')
|
||||
if res.status_code == 200:
|
||||
with open(countries_json_path, 'w') as f:
|
||||
f.write(res.text)
|
||||
self.stdout.write(self.style.SUCCESS('countries+regions+states.json downloaded successfully'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR('Error downloading countries+regions.json'))
|
||||
self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json'))
|
||||
return
|
||||
elif not os.path.isfile(countries_json_path):
|
||||
self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file'))
|
||||
return
|
||||
elif os.path.getsize(countries_json_path) == 0:
|
||||
self.stdout.write(self.style.ERROR('countries+regions+states.json is empty'))
|
||||
elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0:
|
||||
self.stdout.write(self.style.WARNING('Some region data is missing. Re-importing all data.'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.'))
|
||||
return
|
||||
|
||||
with open(countries_json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
f = open(countries_json_path, 'rb')
|
||||
parser = ijson.items(f, 'item')
|
||||
|
||||
with transaction.atomic():
|
||||
existing_countries = {country.country_code: country for country in Country.objects.all()}
|
||||
existing_regions = {region.id: region for region in Region.objects.all()}
|
||||
existing_cities = {city.id: city for city in City.objects.all()}
|
||||
|
||||
countries_to_create = []
|
||||
regions_to_create = []
|
||||
countries_to_update = []
|
||||
regions_to_update = []
|
||||
cities_to_create = []
|
||||
cities_to_update = []
|
||||
|
||||
processed_country_codes = set()
|
||||
processed_region_ids = set()
|
||||
processed_city_ids = set()
|
||||
|
||||
for country in data:
|
||||
for country in parser:
|
||||
country_code = country['iso2']
|
||||
country_name = country['name']
|
||||
country_subregion = country['subregion']
|
||||
country_capital = country['capital']
|
||||
longitude = round(float(country['longitude']), 6) if country['longitude'] else None
|
||||
latitude = round(float(country['latitude']), 6) if country['latitude'] else None
|
||||
|
||||
processed_country_codes.add(country_code)
|
||||
|
||||
|
@ -76,18 +100,21 @@ class Command(BaseCommand):
|
|||
country_obj.name = country_name
|
||||
country_obj.subregion = country_subregion
|
||||
country_obj.capital = country_capital
|
||||
country_obj.longitude = longitude
|
||||
country_obj.latitude = latitude
|
||||
countries_to_update.append(country_obj)
|
||||
else:
|
||||
country_obj = Country(
|
||||
name=country_name,
|
||||
country_code=country_code,
|
||||
subregion=country_subregion,
|
||||
capital=country_capital
|
||||
capital=country_capital,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
)
|
||||
countries_to_create.append(country_obj)
|
||||
|
||||
saveCountryFlag(country_code)
|
||||
self.stdout.write(self.style.SUCCESS(f'Country {country_name} prepared'))
|
||||
|
||||
if country['states']:
|
||||
for state in country['states']:
|
||||
|
@ -96,6 +123,11 @@ class Command(BaseCommand):
|
|||
latitude = round(float(state['latitude']), 6) if state['latitude'] else None
|
||||
longitude = round(float(state['longitude']), 6) if state['longitude'] else None
|
||||
|
||||
# Check for duplicate regions
|
||||
if state_id in processed_region_ids:
|
||||
# self.stdout.write(self.style.ERROR(f'State {state_id} already processed'))
|
||||
continue
|
||||
|
||||
processed_region_ids.add(state_id)
|
||||
|
||||
if state_id in existing_regions:
|
||||
|
@ -114,7 +146,40 @@ class Command(BaseCommand):
|
|||
latitude=latitude
|
||||
)
|
||||
regions_to_create.append(region_obj)
|
||||
self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared'))
|
||||
# self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared'))
|
||||
|
||||
if 'cities' in state and len(state['cities']) > 0:
|
||||
for city in state['cities']:
|
||||
city_id = f"{state_id}-{city['id']}"
|
||||
city_name = city['name']
|
||||
latitude = round(float(city['latitude']), 6) if city['latitude'] else None
|
||||
longitude = round(float(city['longitude']), 6) if city['longitude'] else None
|
||||
|
||||
# Check for duplicate cities
|
||||
if city_id in processed_city_ids:
|
||||
# self.stdout.write(self.style.ERROR(f'City {city_id} already processed'))
|
||||
continue
|
||||
|
||||
processed_city_ids.add(city_id)
|
||||
|
||||
if city_id in existing_cities:
|
||||
city_obj = existing_cities[city_id]
|
||||
city_obj.name = city_name
|
||||
city_obj.region = region_obj
|
||||
city_obj.longitude = longitude
|
||||
city_obj.latitude = latitude
|
||||
cities_to_update.append(city_obj)
|
||||
else:
|
||||
city_obj = City(
|
||||
id=city_id,
|
||||
name=city_name,
|
||||
region=region_obj,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
)
|
||||
cities_to_create.append(city_obj)
|
||||
# self.stdout.write(self.style.SUCCESS(f'City {city_id} prepared'))
|
||||
|
||||
else:
|
||||
state_id = f"{country_code}-00"
|
||||
processed_region_ids.add(state_id)
|
||||
|
@ -130,18 +195,35 @@ class Command(BaseCommand):
|
|||
country=country_obj
|
||||
)
|
||||
regions_to_create.append(region_obj)
|
||||
self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}'))
|
||||
# self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}'))
|
||||
for i in tqdm(range(0, len(countries_to_create), batch_size), desc="Processing countries"):
|
||||
batch = countries_to_create[i:i + batch_size]
|
||||
Country.objects.bulk_create(batch)
|
||||
|
||||
# Bulk create new countries and regions
|
||||
Country.objects.bulk_create(countries_to_create)
|
||||
Region.objects.bulk_create(regions_to_create)
|
||||
for i in tqdm(range(0, len(regions_to_create), batch_size), desc="Processing regions"):
|
||||
batch = regions_to_create[i:i + batch_size]
|
||||
Region.objects.bulk_create(batch)
|
||||
|
||||
# Bulk update existing countries and regions
|
||||
Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital'])
|
||||
Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude'])
|
||||
for i in tqdm(range(0, len(cities_to_create), batch_size), desc="Processing cities"):
|
||||
batch = cities_to_create[i:i + batch_size]
|
||||
City.objects.bulk_create(batch)
|
||||
|
||||
# Delete countries and regions that are no longer in the data
|
||||
# Process updates in batches
|
||||
for i in range(0, len(countries_to_update), batch_size):
|
||||
batch = countries_to_update[i:i + batch_size]
|
||||
for i in tqdm(range(0, len(countries_to_update), batch_size), desc="Updating countries"):
|
||||
batch = countries_to_update[i:i + batch_size]
|
||||
Country.objects.bulk_update(batch, ['name', 'subregion', 'capital', 'longitude', 'latitude'])
|
||||
|
||||
for i in tqdm(range(0, len(regions_to_update), batch_size), desc="Updating regions"):
|
||||
batch = regions_to_update[i:i + batch_size]
|
||||
Region.objects.bulk_update(batch, ['name', 'country', 'longitude', 'latitude'])
|
||||
|
||||
for i in tqdm(range(0, len(cities_to_update), batch_size), desc="Updating cities"):
|
||||
batch = cities_to_update[i:i + batch_size]
|
||||
City.objects.bulk_update(batch, ['name', 'region', 'longitude', 'latitude'])
|
||||
Country.objects.exclude(country_code__in=processed_country_codes).delete()
|
||||
Region.objects.exclude(id__in=processed_region_ids).delete()
|
||||
City.objects.exclude(id__in=processed_city_ids).delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('All data imported successfully'))
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-02 00:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('worldtravel', '0010_country_capital'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='country',
|
||||
name='latitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='country',
|
||||
name='longitude',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
|
||||
),
|
||||
]
|
27
backend/server/worldtravel/migrations/0012_city.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-09 15:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('worldtravel', '0011_country_latitude_country_longitude'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='City',
|
||||
fields=[
|
||||
('id', models.CharField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('region', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.region')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Cities',
|
||||
},
|
||||
),
|
||||
]
|
24
backend/server/worldtravel/migrations/0013_visitedcity.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-09 17:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('worldtravel', '0012_city'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VisitedCity',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')),
|
||||
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-09 18:08
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('worldtravel', '0013_visitedcity'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='visitedcity',
|
||||
options={'verbose_name_plural': 'Visited Cities'},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 5.0.8 on 2025-01-13 17:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('worldtravel', '0014_alter_visitedcity_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='insert_id',
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='country',
|
||||
name='insert_id',
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='region',
|
||||
name='insert_id',
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -15,6 +15,9 @@ class Country(models.Model):
|
|||
country_code = models.CharField(max_length=2, unique=True) #iso2 code
|
||||
subregion = models.CharField(max_length=100, blank=True, null=True)
|
||||
capital = models.CharField(max_length=100, blank=True, null=True)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Country"
|
||||
|
@ -29,6 +32,21 @@ class Region(models.Model):
|
|||
country = models.ForeignKey(Country, on_delete=models.CASCADE)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class City(models.Model):
|
||||
id = models.CharField(primary_key=True)
|
||||
name = models.CharField(max_length=100)
|
||||
region = models.ForeignKey(Region, on_delete=models.CASCADE)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Cities"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -45,4 +63,21 @@ class VisitedRegion(models.Model):
|
|||
def save(self, *args, **kwargs):
|
||||
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
|
||||
raise ValidationError("Region already visited by user.")
|
||||
super().save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class VisitedCity(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
city = models.ForeignKey(City, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists():
|
||||
raise ValidationError("City already visited by user.")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Visited Cities"
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
from .models import Country, Region, VisitedRegion
|
||||
from .models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
|
||||
|
@ -29,14 +29,28 @@ class CountrySerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = Country
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits']
|
||||
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits', 'longitude', 'latitude', 'capital']
|
||||
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
num_cities = serializers.SerializerMethodField()
|
||||
country_name = serializers.CharField(source='country.name', read_only=True)
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude']
|
||||
read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities', 'country_name']
|
||||
|
||||
def get_num_cities(self, obj):
|
||||
return City.objects.filter(region=obj).count()
|
||||
|
||||
class CitySerializer(serializers.ModelSerializer):
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
country_name = serializers.CharField(source='region.country.name', read_only=True
|
||||
)
|
||||
class Meta:
|
||||
model = City
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude', 'region_name', 'country_name']
|
||||
|
||||
class VisitedRegionSerializer(CustomModelSerializer):
|
||||
longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True)
|
||||
|
@ -46,4 +60,14 @@ class VisitedRegionSerializer(CustomModelSerializer):
|
|||
class Meta:
|
||||
model = VisitedRegion
|
||||
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name']
|
||||
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
|
||||
|
||||
class VisitedCitySerializer(CustomModelSerializer):
|
||||
longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True)
|
||||
latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True)
|
||||
name = serializers.CharField(source='city.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VisitedCity
|
||||
fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name']
|
||||
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
|
|
@ -2,15 +2,17 @@
|
|||
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country
|
||||
|
||||
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region
|
||||
router = DefaultRouter()
|
||||
router.register(r'countries', CountryViewSet, basename='countries')
|
||||
router.register(r'regions', RegionViewSet, basename='regions')
|
||||
router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion')
|
||||
router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'),
|
||||
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country')
|
||||
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
|
||||
path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
|
||||
path('regions/<str:region_id>/cities/visits/', visits_by_region, name='visits-by-region'),
|
||||
]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.shortcuts import render
|
||||
from .models import Country, Region, VisitedRegion
|
||||
from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer
|
||||
from .models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
@ -33,6 +33,23 @@ def visits_by_country(request, country_code):
|
|||
serializer = VisitedRegionSerializer(visits, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def cities_by_region(request, region_id):
|
||||
region = get_object_or_404(Region, id=region_id)
|
||||
cities = City.objects.filter(region=region).order_by('name')
|
||||
serializer = CitySerializer(cities, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def visits_by_region(request, region_id):
|
||||
region = get_object_or_404(Region, id=region_id)
|
||||
visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id)
|
||||
|
||||
serializer = VisitedCitySerializer(visits, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class CountryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Country.objects.all().order_by('name')
|
||||
serializer_class = CountrySerializer
|
||||
|
@ -93,4 +110,46 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def destroy(self, request, **kwargs):
|
||||
# delete by region id
|
||||
region = get_object_or_404(Region, id=kwargs['pk'])
|
||||
visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region)
|
||||
if visited_region.exists():
|
||||
visited_region.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class VisitedCityViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = VisitedCitySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return VisitedCity.objects.filter(user_id=self.request.user.id)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user_id=self.request.user)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
request.data['user_id'] = request.user
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
# if the region is not visited, visit it
|
||||
region = serializer.validated_data['city'].region
|
||||
if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists():
|
||||
VisitedRegion.objects.create(user_id=request.user, region=region)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def destroy(self, request, **kwargs):
|
||||
# delete by city id
|
||||
city = get_object_or_404(City, id=kwargs['pk'])
|
||||
visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city)
|
||||
if visited_city.exists():
|
||||
visited_city.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)
|
BIN
brand/adventurelog.png
Normal file
After Width: | Height: | Size: 87 KiB |
313
brand/adventurelog.svg
Normal file
|
@ -0,0 +1,313 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="2000"
|
||||
height="2000"
|
||||
viewBox="0 0 529.16665 529.16666"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="AdventureLog.svg"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
|
||||
inkscape:export-filename="AdventureLog.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.35190083"
|
||||
inkscape:cx="1140.9464"
|
||||
inkscape:cy="1112.5293"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1131"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter389"
|
||||
x="-1.0282219"
|
||||
y="-0.73747355"
|
||||
width="3.0639595"
|
||||
height="2.4803376">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood388" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur388" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset388" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite388" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite389" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter391"
|
||||
x="-0.59415994"
|
||||
y="-1.3323052"
|
||||
width="2.1926628"
|
||||
height="3.6743487">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood389" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur389" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset389" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite390" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite391" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter393"
|
||||
x="-1.1482379"
|
||||
y="-0.96121423"
|
||||
width="3.3048687"
|
||||
height="2.9294544">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood391" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur391" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset391" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite392" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite393" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter395"
|
||||
x="-1.3398814"
|
||||
y="-1.1275613"
|
||||
width="3.6895566"
|
||||
height="3.2633644">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood393" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur393" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset393" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite394" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite395" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter397"
|
||||
x="-1.8571666"
|
||||
y="-0.84804253"
|
||||
width="4.7279079"
|
||||
height="2.7022837">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood395" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur395" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset395" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite396" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite397" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter427"
|
||||
x="-0.096054554"
|
||||
y="-0.10772674"
|
||||
width="1.1947073"
|
||||
height="1.2117496">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood426" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="13.320559"
|
||||
id="feGaussianBlur426" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset426" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite426" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite427" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<g
|
||||
id="g379">
|
||||
<path
|
||||
style="display:inline;fill:#48b5ff;fill-opacity:1;stroke-width:94.45;stroke-dasharray:none"
|
||||
d="m 971.77794,1568.1491 -215.24252,-108.5775 -232.07676,98.4805 -232.07678,98.4806 -0.63034,-550.1231 c -0.34668,-302.56774 0.21559,-550.94143 1.2495,-551.94157 1.03391,-1.00014 105.33804,-45.69109 231.78696,-99.31323 L 754.69512,357.65999 970.68644,466.2139 c 118.79516,59.70465 217.23796,108.5539 218.76156,108.5539 1.5236,0 108.4326,-50.70974 237.5755,-112.68831 129.1428,-61.97858 245.2097,-117.568 257.9264,-123.53205 l 23.1212,-10.84372 -0.6303,551.00102 -0.6303,551.00106 -257.396,123.4976 c -141.5678,67.9237 -258.5206,123.5034 -259.895,123.5104 -1.3745,0.01 -99.3582,-48.8471 -217.74156,-108.5647 z"
|
||||
id="path1"
|
||||
transform="scale(0.26458333)" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter389)"
|
||||
d="m 154.50783,108.88503 c -2.85572,6.51915 -3.99705,17.36183 -0.2277,23.7036 3.00168,5.05017 8.28922,6.62967 13.3295,9.04742 3.90851,1.87485 7.96149,3.93177 11.47307,6.51256 13.62566,10.01398 23.98335,27.67115 20.06743,44.94435 -0.12449,0.54916 -1.48104,7.01695 -1.85641,7.87642 -2.34196,5.36214 -12.56252,15.09064 -18.05999,17.60459 -0.31647,0.14472 -6.14257,1.6119 -6.77744,1.77975 -5.74767,1.51955 -11.84,3.00805 -16.77513,6.51256 -2.81536,1.99923 -13.27557,11.47452 -14.84205,14.54 -0.76687,1.5007 -1.22537,3.14442 -1.97026,4.65615 -2.34545,4.75997 -5.79169,9.60118 -9.20077,13.63154 -3.11382,3.68129 -2.36218,2.17313 -5.86897,5.3764 -6.0653,5.54035 -12.38233,10.68303 -18.66873,15.97822 -2.95625,2.4901 -1.77292,2.02049 -4.80717,4.24024 -4.376145,3.20143 -19.485134,11.83259 -25.104617,8.25513 -5.798267,-3.69128 -1.637855,-18.91136 -2.537182,-24.27052 -0.665342,-3.96483 -2.868842,-7.73278 -3.824359,-11.66126 -1.060926,-4.36186 0.244798,-8.61424 0.415894,-12.95078 0.166198,-4.2124 0.437509,-8.63608 -0.03717,-12.8346 -0.54496,-4.82011 -2.197963,-8.2219 -2.197963,-13.32717 0,-3.83658 -0.26317,-7.9553 0.0395,-11.77513 0.113016,-1.42634 0.682535,-2.78477 0.871283,-4.20307 0.705311,-5.2999 1.237988,-11.08737 0.831787,-16.4336 -0.205095,-2.69936 5.511498,-10.74899 5.093496,-13.38624 -0.980816,-6.18814 -7.14978,-6.25695 -6.304002,-12.32247 0.451585,-3.23855 0.187248,-7.10749 1.740246,-10.07205 0.835928,-1.59571 1.639732,-4.10023 2.915902,-5.3764 3.741116,-3.74112 13.330719,-6.06402 18.250511,-7.60923 3.127833,-0.98238 6.027592,-2.45779 8.975394,-3.86385 3.27336,-1.56136 5.87838,-3.71819 8.93589,-5.60178 3.52017,-2.16861 7.75174,-3.29655 11.51025,-4.96052 11.45567,-5.07163 22.44821,-10.89093 34.60976,-14.01026 z"
|
||||
id="path2"
|
||||
sodipodi:nodetypes="csssscssssssssssssssssssssssssssc" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter393)"
|
||||
d="m 282.71911,236.75014 c -1.07341,-0.51813 -2.0389,-1.39597 -3.22027,-1.55438 -1.88367,-0.25258 -5.13392,0.85934 -7.00513,1.44053 -8.45275,2.62538 -18.44379,6.81757 -22.49075,15.37179 -3.20748,6.77976 -1.80841,13.94405 -1.21283,21.05255 0.70345,8.39597 0.60913,17.64626 3.06924,25.78307 3.80766,12.59377 15.78781,28.09023 29.11717,31.23845 5.76255,1.36104 8.68662,1.0038 15.10925,0.26487 11.5788,-1.33212 23.20626,-7.9298 31.04795,-16.39408 3.10414,-3.3506 5.50955,-7.21715 8.59666,-10.6018 3.18743,-3.49465 5.51775,-7.04064 8.06463,-10.9805 3.48445,-5.39025 5.91,-9.43047 3.44564,-16.12924 -1.0297,-2.79895 -5.0392,-5.98461 -7.08181,-8.10411 -4.91808,-5.10316 -8.81666,-9.96675 -9.42845,-17.30255 -0.51679,-6.19651 0.806,-12.46011 0.11382,-18.62923 -0.87048,-7.75843 -3.35968,-15.22014 -5.56458,-22.67895 -1.97014,-6.66463 -5.2514,-14.24288 -11.70078,-17.79745 -15.70897,-8.65796 -36.07811,2.92981 -49.03591,11.73795 -1.87759,1.2763 -4.03614,1.97474 -5.86898,3.29462 -1.50247,1.08197 -2.65518,2.55672 -4.05205,3.74768 -2.7825,2.37234 -5.73488,4.72293 -8.59435,7.00513 -6.38056,5.09245 -15.28401,9.78925 -16.88899,18.59206 -0.67926,3.72553 7.14966,3.49307 9.04975,3.44332 9.16411,-0.23998 18.38306,-4.78561 26.08975,-9.42615 2.57984,-1.55343 5.60029,-3.28025 8.59434,-3.90103 3.15601,-0.65434 6.73357,-0.98782 9.69333,0.56924 1.40962,0.74156 2.32511,2.61628 3.3713,3.74769 3.81595,4.12676 4.11615,7.5098 -3.21795,6.21052 z"
|
||||
id="path5" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter391)"
|
||||
d="m 99.110381,433.18186 c 4.670059,-2.86644 7.566889,-7.59439 11.398729,-11.3964 11.22457,-11.13721 20.23699,-24.24948 28.43641,-37.74871 5.53049,-9.10519 9.71389,-19.38771 16.16872,-27.90433 3.11752,-4.11332 7.50709,-7.12695 11.43358,-10.41361 4.20791,-3.52221 7.6504,-6.81593 12.8741,-8.67103 15.36185,-5.45544 26.73636,1.95538 38.47129,11.2454 3.5267,2.79191 7.05706,4.28564 10.90616,6.47539 4.29758,2.44485 7.73021,6.21292 12.19102,8.44333 8.94937,4.47469 19.38222,5.65478 29.15668,6.89126 7.14631,0.90405 14.16066,2.50237 21.1664,4.12641 16.46849,3.81768 33.64484,8.74959 32.67668,29.34489 -0.28171,5.99241 -3.32624,12.60742 -8.02513,16.39408 -3.91306,3.15339 -9.22134,3.33169 -13.89873,4.20307 -5.87557,1.09461 -11.90458,2.75058 -17.94615,2.91592 -3.19683,0.0875 -11.4417,-2.50979 -14.9954,-3.33179 -3.80158,-0.87937 -8.26721,-0.9415 -11.73793,-2.84158 -3.87055,-2.11894 -6.90769,-5.47743 -10.45078,-8.0251 -4.87127,-3.50271 -1.08518,-0.58992 -4.96051,-2.91589 -3.30897,-1.98607 -6.204,-4.669 -9.57948,-6.54974 -5.1211,-2.8534 -13.86293,-3.58071 -19.69104,-4.77231 -5.67771,-1.16089 -11.01578,-3.30923 -16.81231,-4.01257 -13.91552,-1.68849 -29.45142,5.70987 -40.9318,13.09947 -2.56659,1.65206 -4.97173,3.56039 -7.42102,5.33924 -2.67583,1.94339 -5.80257,3.32094 -8.7082,4.88384 -7.53479,4.05288 -15.4307,7.2287 -22.90898,11.35922 -2.00201,1.1058 -11.46055,6.02861 -13.17615,5.68079 -1.32827,-0.26929 -2.33944,-2.21337 -3.636159,-1.81925 -2.267678,0.68921 -3.219347,3.63569 -5.339231,4.69564"
|
||||
id="path6" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter395)"
|
||||
d="m 450.19631,298.04907 c -5.5282,0.50496 -11.31189,-0.22132 -16.58461,1.51487 -12.17369,4.0086 -28.70549,15.28393 -34.1172,28.28309 -2.07438,4.98277 -2.95732,10.25334 -3.37129,15.59946 -0.22418,2.89552 -0.0933,5.87015 -0.83177,8.70821 -1.64349,6.31634 -4.7022,13.0625 -8.78488,18.17616 -2.91534,3.65154 -6.67846,6.51549 -10.14873,9.54 -8.24569,7.18651 -23.60925,23.91071 -21.96103,36.31049 0.19262,1.44907 0.77642,2.27965 2.1213,2.87872 2.17652,0.96954 6.3614,-0.53234 8.63153,-0.8341 7.76113,-1.03164 12.12755,-1.31003 19.57718,-5.03486 1.44111,-0.72054 2.84964,-1.3653 4.31694,-2.04462 6.05637,-2.80398 11.89083,-6.01507 17.83461,-9.04973 2.26536,-1.15663 4.74779,-1.77562 7.04231,-2.87642 2.15358,-1.03317 3.83749,-2.63954 5.98281,-3.67334 1.5544,-0.74904 3.25289,-1.02836 4.80949,-1.70307 1.86055,-0.80645 3.54978,-1.97313 5.33924,-2.87872 2.17898,-1.10271 4.61735,-1.2749 6.92846,-1.8936 1.4836,-0.39716 2.68676,-1.23536 4.08921,-1.81692 1.65156,-0.68485 3.50653,-0.57332 5.22539,-0.98512 1.56427,-0.37476 2.48695,-2.11201 3.74769,-2.99024 0.6309,-0.4395 1.52495,-0.5375 2.00745,-1.13618 0.48395,-0.60047 0.25164,-1.54802 0.6064,-2.23279 0.46074,-0.88932 1.51323,-1.21002 1.96794,-2.1213 1.8632,-3.73398 0.31491,-12.51823 0.41823,-16.62178 0.11186,-4.44304 0.41844,-8.86217 0.71795,-13.29 0.23315,-3.44704 -0.22538,-6.93523 -0.22538,-10.3741 0,-1.49648 0.38465,-2.89922 0.30203,-4.39359 -0.0821,-1.48571 -0.45538,-2.97958 -0.45538,-4.46796 0,-3.04234 0.0308,0.34052 0.49258,-2.53484 0.34938,-2.17554 0.005,-4.54488 0.0767,-6.74026 0.0808,-2.47037 0.58761,-4.89522 0.37872,-7.38386 -0.13973,-1.66495 -1.12795,-2.77178 -1.32667,-4.39127 -0.18376,-1.49751 0.63254,-5.63655 0,-6.74026 -0.3973,-0.69326 -1.71445,-0.36851 -2.23282,-0.72027 -0.91319,-0.61968 -1.71622,-1.38785 -2.57435,-2.0818 z"
|
||||
id="path7" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter397)"
|
||||
d="m 375.33553,121.34324 c 3.39913,22.93503 -2.23867,43.81133 -8.17846,65.50203 -3.10168,11.32658 -4.27915,22.46486 -4.96051,34.11486 -0.32861,5.61878 -0.89162,6.02837 -0.26487,12.41872 0.34464,3.51408 1.85081,7.80185 3.29461,11.01768 1.13398,2.52573 4.32978,4.06396 6.85411,4.73282 14.37217,3.80815 26.65789,-2.23088 33.69898,-15.18127 6.74126,-12.399 4.57229,-24.42084 3.86151,-37.75102 -0.38232,-7.17036 -0.76689,-14.97137 -0.26487,-22.11205 0.6106,-8.68483 5.02068,-16.55987 8.71053,-24.231 2.27978,-4.73962 3.62913,-9.80406 5.52744,-14.69103 1.30437,-3.35796 2.65044,-5.86766 3.82436,-9.39129 1.51609,-4.55069 0.62532,-9.15948 1.17333,-13.78023 0.47889,-4.03804 2.7718,-7.5475 3.82436,-11.39873 1.04624,-3.828179 1.90934,-7.787484 2.87872,-11.661277"
|
||||
id="path8" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#afafaf;stroke-width:10;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter427)"
|
||||
d="M 456.91785,381.08869 314.6716,449.29929 199.88458,391.55258 72.03927,445.77735 72.039268,143.494 199.88458,89.269234 314.6716,147.01594 456.91785,78.805342 Z"
|
||||
id="path2-2"
|
||||
sodipodi:nodetypes="ccccccccc" />
|
||||
</g>
|
||||
<path
|
||||
id="rect378"
|
||||
style="fill:#6d6d6d;fill-opacity:0.31908;stroke-width:16.7412"
|
||||
d="m 200.16234,83.744919 114.47572,57.762111 0,313.26052 -114.47572,-57.8148 z"
|
||||
sodipodi:nodetypes="ccccc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 16 KiB |
BIN
brand/banner.png
Normal file
After Width: | Height: | Size: 882 KiB |
BIN
brand/screenshots/adventures.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
brand/screenshots/countries.png
Normal file
After Width: | Height: | Size: 214 KiB |
BIN
brand/screenshots/dashboard.png
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
brand/screenshots/details.png
Normal file
After Width: | Height: | Size: 848 KiB |
BIN
brand/screenshots/edit.png
Normal file
After Width: | Height: | Size: 656 KiB |
BIN
brand/screenshots/itinerary.png
Normal file
After Width: | Height: | Size: 735 KiB |
BIN
brand/screenshots/map.png
Normal file
After Width: | Height: | Size: 478 KiB |
BIN
brand/screenshots/regions.png
Normal file
After Width: | Height: | Size: 159 KiB |
1
cdn/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
data/
|