├── api
├── __init__.py
├── routers
│ ├── __init__.py
│ ├── api.py
│ └── healthcheck.py
├── tests
│ ├── __init__.py
│ └── routes
│ │ ├── __init__.py
│ │ └── test_healthcheck.py
├── migrations
│ ├── README
│ ├── script.py.mako
│ ├── versions
│ │ ├── 20210305_151900_update_comments_model.py
│ │ ├── 20210322_211305_create_lat_and_lng_on_meetups.py
│ │ ├── 20210312_133251_add_lat_and_lng_columns_to_retailer.py
│ │ └── 20201120_150602_create_users_table.py
│ └── env.py
├── models
│ ├── __init__.py
│ └── user.py
├── constants.py
├── README.org
├── main.py
├── database.py
├── alembic.ini
└── settings.py
├── .flaskenv
├── app
├── schemas
│ ├── __init__.py
│ ├── config.py
│ ├── community.py
│ ├── tag.py
│ ├── meetup.py
│ ├── post.py
│ ├── responses.py
│ ├── comment.py
│ ├── retailer.py
│ └── user.py
├── tests
│ ├── __init__.py
│ └── models
│ │ ├── __init__.py
│ │ └── test_user.py
├── integration_tests
│ └── routes
│ │ ├── __init__.py
│ │ ├── test_posts.py
│ │ ├── test_comments.py
│ │ └── test_auth.py
├── models
│ ├── db.py
│ ├── posts_tag.py
│ ├── saved_post.py
│ ├── saved_comment.py
│ ├── thread.py
│ ├── tag.py
│ ├── post_image.py
│ ├── retailer_image.py
│ ├── __init__.py
│ ├── community.py
│ ├── comment_rating.py
│ ├── message.py
│ ├── post_rating.py
│ ├── retailer_rating.py
│ ├── meetup.py
│ ├── retailer.py
│ ├── user.py
│ ├── comment.py
│ └── post.py
├── forms
│ ├── create_retailer_rating.py
│ ├── create_post.py
│ ├── create_comment.py
│ ├── create_post_rating.py
│ ├── create_comment_rating.py
│ ├── create_retailer.py
│ ├── create_community.py
│ ├── __init__.py
│ ├── create_meetup.py
│ ├── login.py
│ └── signup.py
├── routes
│ ├── api
│ │ ├── posts_image.py
│ │ ├── lat_long.py
│ │ ├── __init__.py
│ │ ├── comment.py
│ │ ├── user.py
│ │ ├── auth.py
│ │ └── meetup.py
│ └── __init__.py
├── seeds
│ ├── posts.py
│ ├── threads.py
│ ├── meetups.py
│ ├── comments.py
│ ├── post_images.py
│ ├── retailers.py
│ ├── post_ratings.py
│ ├── communities.py
│ ├── comment_ratings.py
│ ├── retailer_ratings.py
│ ├── users.py
│ ├── post_images.json
│ └── __init__.py
├── __init__.py
├── conftest.py
├── config.py
└── helpers.py
├── react-app
├── .nvmrc
├── .prettierignore
├── src
│ ├── setupTests.js
│ ├── components
│ │ ├── Map
│ │ │ ├── index.js
│ │ │ └── Map.js
│ │ ├── About
│ │ │ ├── index.js
│ │ │ └── About.js
│ │ ├── Post
│ │ │ └── index.js
│ │ ├── Meetup
│ │ │ ├── index.js
│ │ │ └── Meetup.js
│ │ ├── NavBar
│ │ │ └── index.js
│ │ ├── Comment
│ │ │ └── index.js
│ │ ├── Sidebar
│ │ │ ├── index.js
│ │ │ └── Sidebar.js
│ │ ├── parts
│ │ │ ├── Score
│ │ │ │ ├── index.js
│ │ │ │ └── Score.js
│ │ │ ├── Upvote
│ │ │ │ ├── index.js
│ │ │ │ └── Upvote.js
│ │ │ ├── DivCard
│ │ │ │ ├── index.js
│ │ │ │ └── DivCard.js
│ │ │ ├── Downvote
│ │ │ │ ├── index.js
│ │ │ │ └── Downvote.js
│ │ │ ├── UserMenu
│ │ │ │ ├── index.js
│ │ │ │ └── UserMenu.js
│ │ │ ├── UserName
│ │ │ │ ├── index.js
│ │ │ │ └── UserName.js
│ │ │ ├── FormTitle
│ │ │ │ ├── index.js
│ │ │ │ └── FormTitle.js
│ │ │ ├── NavButton
│ │ │ │ ├── index.js
│ │ │ │ └── NavButton.js
│ │ │ ├── EditButton
│ │ │ │ ├── index.js
│ │ │ │ └── EditButton.js
│ │ │ ├── FormErrors
│ │ │ │ ├── index.js
│ │ │ │ └── FormErrors.js
│ │ │ ├── InputField
│ │ │ │ ├── index.js
│ │ │ │ └── InputField.js
│ │ │ ├── SaveButton
│ │ │ │ ├── index.js
│ │ │ │ └── SaveButton.js
│ │ │ ├── DeleteButton
│ │ │ │ ├── index.js
│ │ │ │ └── DeleteButton.js
│ │ │ ├── SubmitFormButton
│ │ │ │ ├── index.js
│ │ │ │ └── SubmitFormButton.js
│ │ │ └── DeleteConfirmation
│ │ │ │ ├── index.js
│ │ │ │ └── DeleteConfirmation.js
│ │ ├── PostPage
│ │ │ ├── index.js
│ │ │ └── PostPage.js
│ │ ├── Retailer
│ │ │ ├── index.js
│ │ │ └── Retailer.js
│ │ ├── UserCard
│ │ │ ├── index.js
│ │ │ └── UserCard.js
│ │ ├── Community
│ │ │ ├── index.js
│ │ │ └── Community.js
│ │ ├── MeetupPage
│ │ │ ├── index.js
│ │ │ └── MeetupPage.js
│ │ ├── SearchBar
│ │ │ ├── index.js
│ │ │ └── SearchBar.js
│ │ ├── ProfilePage
│ │ │ └── index.js
│ │ ├── AdvSearchBar
│ │ │ └── index.js
│ │ ├── CreateMeetup
│ │ │ └── index.js
│ │ ├── LogoutButton
│ │ │ ├── index.js
│ │ │ └── LogoutButton.js
│ │ ├── PageNotFound
│ │ │ ├── index.js
│ │ │ └── PageNotFound.js
│ │ ├── RetailerPage
│ │ │ ├── index.js
│ │ │ └── RetailerPage.js
│ │ ├── UserNotFound
│ │ │ ├── index.js
│ │ │ └── UserNotFound.js
│ │ ├── SearchResults
│ │ │ └── index.js
│ │ ├── DarkModeToggle
│ │ │ ├── index.js
│ │ │ └── DarkModeToggle.js
│ │ ├── PostsContainer
│ │ │ └── index.js
│ │ ├── RetailerRating
│ │ │ ├── index.js
│ │ │ └── RetailerRating.js
│ │ ├── CollpasedSidebar
│ │ │ └── index.js
│ │ ├── MeetupsContainer
│ │ │ ├── index.js
│ │ │ └── MeetupsContainer.js
│ │ ├── CreateCommentForm
│ │ │ ├── index.js
│ │ │ └── CreateCommentForm.js
│ │ ├── RetailerRatingForm
│ │ │ ├── index.js
│ │ │ └── RetailerRatingForm.js
│ │ ├── RetailersContainer
│ │ │ ├── index.js
│ │ │ └── RetailersContainer.js
│ │ ├── CommentTreeContainer
│ │ │ ├── index.js
│ │ │ └── CommentTreeContainer.js
│ │ ├── CommunitiesContainer
│ │ │ ├── index.js
│ │ │ └── CommunitiesContainer.js
│ │ ├── CreateCommunityForm
│ │ │ ├── index.js
│ │ │ └── CreateCommunityForm.js
│ │ ├── CommentThreadContainer
│ │ │ ├── index.js
│ │ │ └── CommentThreadContainer.js
│ │ ├── RetailerRatingsContainer
│ │ │ ├── index.js
│ │ │ └── RetailerRatingsContainer.js
│ │ ├── EditCommentForm
│ │ │ ├── index.js
│ │ │ └── EditCommentForm.js
│ │ ├── EditPostForm
│ │ │ └── index.js
│ │ ├── LoginForm
│ │ │ └── index.js
│ │ ├── EditRetailerRatingForm
│ │ │ ├── index.js
│ │ │ └── EditRetailerRatingForm.js
│ │ ├── SignUpForm
│ │ │ └── index.js
│ │ ├── EditMeetupForm
│ │ │ └── index.js
│ │ ├── CreatePostForm
│ │ │ └── index.js
│ │ └── __tests__
│ │ │ └── Post.js
│ ├── images
│ │ └── logo.png
│ ├── utils
│ │ ├── convertFormErrors.js
│ │ └── localeDateString.js
│ ├── index.css
│ ├── context
│ │ ├── CommentContext.js
│ │ ├── DarkModeContext.js
│ │ ├── CreatePostContext.js
│ │ ├── RetailerRatingContext.js
│ │ ├── SearchContext.js
│ │ ├── CollapsedSidebarContext.js
│ │ ├── AuthContext.js
│ │ ├── index.js
│ │ └── ModalContext.js
│ ├── store
│ │ ├── index.js
│ │ ├── api.js
│ │ ├── sidebar.js
│ │ ├── search.js
│ │ ├── constants.js
│ │ ├── users.js
│ │ ├── selectors.js
│ │ └── communities.js
│ └── index.js
├── global-setup.js
├── public
│ ├── logo.png
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── manifest.json
│ └── index.html
├── .prettierrc.json
├── postcss.config.js
├── .gitignore
├── .babelrc.js
├── tailwind.config.js
└── package.json
├── .python-version
├── migrations
├── README
├── script.py.mako
├── versions
│ ├── 20210305_151900_update_comments_model.py
│ ├── 20210322_211305_create_lat_and_lng_on_meetups.py
│ ├── 20210312_133251_add_lat_and_lng_columns_to_retailer.py
│ └── 20201120_150602_create_users_table.py
└── alembic.ini
├── .gitignore
├── .dockerignore
├── pyproject.toml
├── pytest.ini
├── .env.example
├── setup.cfg
├── .test.env.example
├── docker-compose.org
├── .github
└── workflows
│ ├── test-frontend.yml
│ ├── pre-commit.yml
│ ├── build.yml
│ └── pytest.yml
├── docker-compose.yml
├── Dockerfile
├── dev-requirements.txt
├── .pre-commit-config.yaml
├── Pipfile
├── README.md
├── requirements.txt
└── STARTER-README.md
/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.flaskenv:
--------------------------------------------------------------------------------
1 | FLASK_APP=app
--------------------------------------------------------------------------------
/api/routers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/api/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/schemas/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/react-app/.nvmrc:
--------------------------------------------------------------------------------
1 | 14
2 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.11.2
2 |
--------------------------------------------------------------------------------
/api/tests/routes/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/tests/models/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/integration_tests/routes/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/integration_tests/routes/test_posts.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/integration_tests/routes/test_comments.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/react-app/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 |
--------------------------------------------------------------------------------
/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
2 |
--------------------------------------------------------------------------------
/api/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
2 |
--------------------------------------------------------------------------------
/react-app/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/app/models/db.py:
--------------------------------------------------------------------------------
1 | from flask_sqlalchemy import SQLAlchemy
2 |
3 | db = SQLAlchemy()
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vim
2 | *.env*
3 | __pycache__/
4 | *.py[cod]
5 | .venv
6 | .DS_Store
7 | *.db
8 |
--------------------------------------------------------------------------------
/react-app/global-setup.js:
--------------------------------------------------------------------------------
1 | module.exports = async () => {
2 | process.env.TZ = 'UTC';
3 | };
4 |
--------------------------------------------------------------------------------
/react-app/src/components/Map/index.js:
--------------------------------------------------------------------------------
1 | import Map from './Map';
2 |
3 | export default Map;
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | react-app/node_modules
2 | .venv
3 | Pipfile
4 | Pipfile.lock
5 | .env
6 | .DS_Store
7 |
--------------------------------------------------------------------------------
/react-app/src/components/About/index.js:
--------------------------------------------------------------------------------
1 | import About from './About';
2 |
3 | export default About;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/Post/index.js:
--------------------------------------------------------------------------------
1 | import Post from './Post';
2 |
3 | export default Post;
4 |
--------------------------------------------------------------------------------
/react-app/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/logo.png
--------------------------------------------------------------------------------
/react-app/src/components/Meetup/index.js:
--------------------------------------------------------------------------------
1 | import Meetup from './Meetup';
2 |
3 | export default Meetup;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/NavBar/index.js:
--------------------------------------------------------------------------------
1 | import NavBar from './NavBar';
2 |
3 | export default NavBar;
4 |
--------------------------------------------------------------------------------
/api/models/__init__.py:
--------------------------------------------------------------------------------
1 | # flake8: F401
2 | from api.database import Base
3 | from api.models.user import User
4 |
--------------------------------------------------------------------------------
/react-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/favicon.ico
--------------------------------------------------------------------------------
/react-app/src/components/Comment/index.js:
--------------------------------------------------------------------------------
1 | import Comment from './Comment';
2 |
3 | export default Comment;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/Sidebar/index.js:
--------------------------------------------------------------------------------
1 | import Sidebar from './Sidebar';
2 |
3 | export default Sidebar;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/Score/index.js:
--------------------------------------------------------------------------------
1 | import Score from './Score';
2 |
3 | export default Score;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/Upvote/index.js:
--------------------------------------------------------------------------------
1 | import Upvote from './Upvote';
2 |
3 | export default Upvote;
4 |
--------------------------------------------------------------------------------
/react-app/src/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/src/images/logo.png
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | extend-exclude = 'migrations/versions'
3 |
4 | [tool.isort]
5 | profile = 'black'
6 |
--------------------------------------------------------------------------------
/react-app/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "printWidth": 80,
4 | "trailingComma": "es5"
5 | }
6 |
--------------------------------------------------------------------------------
/react-app/src/components/PostPage/index.js:
--------------------------------------------------------------------------------
1 | import PostPage from './PostPage';
2 |
3 | export default PostPage;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/Retailer/index.js:
--------------------------------------------------------------------------------
1 | import Retailer from './Retailer';
2 |
3 | export default Retailer;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/UserCard/index.js:
--------------------------------------------------------------------------------
1 | import UserCard from './UserCard';
2 |
3 | export default UserCard;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/DivCard/index.js:
--------------------------------------------------------------------------------
1 | import DivCard from './DivCard';
2 |
3 | export default DivCard;
4 |
--------------------------------------------------------------------------------
/react-app/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/favicon-16x16.png
--------------------------------------------------------------------------------
/react-app/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/favicon-32x32.png
--------------------------------------------------------------------------------
/react-app/src/components/Community/index.js:
--------------------------------------------------------------------------------
1 | import Community from './Community';
2 |
3 | export default Community;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/MeetupPage/index.js:
--------------------------------------------------------------------------------
1 | import MeetupPage from './MeetupPage';
2 |
3 | export default MeetupPage;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/SearchBar/index.js:
--------------------------------------------------------------------------------
1 | import SearchBar from './SearchBar';
2 |
3 | export default SearchBar;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/Downvote/index.js:
--------------------------------------------------------------------------------
1 | import Downvote from './Downvote';
2 |
3 | export default Downvote;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/UserMenu/index.js:
--------------------------------------------------------------------------------
1 | import UserMenu from './UserMenu';
2 |
3 | export default UserMenu;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/UserName/index.js:
--------------------------------------------------------------------------------
1 | import UserName from './UserName';
2 |
3 | export default UserName;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/ProfilePage/index.js:
--------------------------------------------------------------------------------
1 | import ProfilePage from './ProfilePage';
2 |
3 | export default ProfilePage;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/FormTitle/index.js:
--------------------------------------------------------------------------------
1 | import FormTitle from './FormTitle';
2 |
3 | export default FormTitle;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/NavButton/index.js:
--------------------------------------------------------------------------------
1 | import NavButton from './NavButton';
2 |
3 | export default NavButton;
4 |
--------------------------------------------------------------------------------
/react-app/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/react-app/src/components/AdvSearchBar/index.js:
--------------------------------------------------------------------------------
1 | import AdvSearchBar from './AdvSearchBar';
2 |
3 | export default AdvSearchBar;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CreateMeetup/index.js:
--------------------------------------------------------------------------------
1 | import CreateMeetup from './CreateMeetup';
2 |
3 | export default CreateMeetup;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/LogoutButton/index.js:
--------------------------------------------------------------------------------
1 | import LogoutButton from './LogoutButton';
2 |
3 | export default LogoutButton;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/PageNotFound/index.js:
--------------------------------------------------------------------------------
1 | import PageNotFound from './PageNotFound';
2 |
3 | export default PageNotFound;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerPage/index.js:
--------------------------------------------------------------------------------
1 | import RetailerPage from './RetailerPage';
2 |
3 | export default RetailerPage;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/UserNotFound/index.js:
--------------------------------------------------------------------------------
1 | import UserNotFound from './UserNotFound';
2 |
3 | export default UserNotFound;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/EditButton/index.js:
--------------------------------------------------------------------------------
1 | import EditButton from './EditButton';
2 |
3 | export default EditButton;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/FormErrors/index.js:
--------------------------------------------------------------------------------
1 | import FormErrors from './FormErrors';
2 |
3 | export default FormErrors;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/InputField/index.js:
--------------------------------------------------------------------------------
1 | import InputField from './InputField';
2 |
3 | export default InputField;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/SaveButton/index.js:
--------------------------------------------------------------------------------
1 | import SaveButton from './SaveButton';
2 |
3 | export default SaveButton;
4 |
--------------------------------------------------------------------------------
/api/constants.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class Tags(str, Enum):
5 | api = "API"
6 | healthcheck = "HEALTHCHECK"
7 |
--------------------------------------------------------------------------------
/react-app/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/react-app/src/components/SearchResults/index.js:
--------------------------------------------------------------------------------
1 | import SearchResults from './SearchResults';
2 |
3 | export default SearchResults;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/DeleteButton/index.js:
--------------------------------------------------------------------------------
1 | import DeleteButton from './DeleteButton';
2 |
3 | export default DeleteButton;
4 |
--------------------------------------------------------------------------------
/react-app/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/react-app/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lazytangent/Qwerkey/HEAD/react-app/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/react-app/src/components/DarkModeToggle/index.js:
--------------------------------------------------------------------------------
1 | import DarkModeToggle from './DarkModeToggle';
2 |
3 | export default DarkModeToggle;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/PostsContainer/index.js:
--------------------------------------------------------------------------------
1 | import PostsContainer from './PostsContainer';
2 |
3 | export default PostsContainer;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerRating/index.js:
--------------------------------------------------------------------------------
1 | import RetailerRating from './RetailerRating';
2 |
3 | export default RetailerRating;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CollpasedSidebar/index.js:
--------------------------------------------------------------------------------
1 | import CollapsedSidebar from './CollapsedSidebar';
2 |
3 | export default CollapsedSidebar;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/MeetupsContainer/index.js:
--------------------------------------------------------------------------------
1 | import MeetupsContainer from './MeetupsContainer';
2 |
3 | export default MeetupsContainer;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CreateCommentForm/index.js:
--------------------------------------------------------------------------------
1 | import CreateCommentForm from './CreateCommentForm';
2 |
3 | export default CreateCommentForm;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/SubmitFormButton/index.js:
--------------------------------------------------------------------------------
1 | import SubmitFormButton from './SubmitFormButton';
2 |
3 | export default SubmitFormButton;
4 |
--------------------------------------------------------------------------------
/app/schemas/config.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class BaseORMModeModel(BaseModel):
5 | class Config:
6 | orm_mode = True
7 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerRatingForm/index.js:
--------------------------------------------------------------------------------
1 | import RetailerRatingForm from './RetailerRatingForm';
2 |
3 | export default RetailerRatingForm;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailersContainer/index.js:
--------------------------------------------------------------------------------
1 | import RetailersContainer from './RetailersContainer';
2 |
3 | export default RetailersContainer;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CommentTreeContainer/index.js:
--------------------------------------------------------------------------------
1 | import CommentTreeContainer from './CommentTreeContainer';
2 |
3 | export default CommentTreeContainer;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CommunitiesContainer/index.js:
--------------------------------------------------------------------------------
1 | import CommunitiesContainer from './CommunitiesContainer';
2 |
3 | export default CommunitiesContainer;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CreateCommunityForm/index.js:
--------------------------------------------------------------------------------
1 | import CreateCommunityForm from './CreateCommunityForm';
2 |
3 | export default CreateCommunityForm;
4 |
--------------------------------------------------------------------------------
/react-app/src/components/CommentThreadContainer/index.js:
--------------------------------------------------------------------------------
1 | import CommentThreadContainer from './CommentThreadContainer';
2 |
3 | export default CommentThreadContainer;
4 |
--------------------------------------------------------------------------------
/app/schemas/community.py:
--------------------------------------------------------------------------------
1 | from app.schemas.config import BaseORMModeModel
2 |
3 |
4 | class MinimalCommunityResponse(BaseORMModeModel):
5 | id: int
6 | name: str
7 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | env_override_existing_values = 1
3 | env_files =
4 | .test.env
5 | testpaths =
6 | app/tests
7 | app/integration_tests
8 | api/tests
9 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerRatingsContainer/index.js:
--------------------------------------------------------------------------------
1 | import RetailerRatingsContainer from './RetailerRatingsContainer';
2 |
3 | export default RetailerRatingsContainer;
4 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | FLASK_APP=app
2 | FLASK_ENV=development
3 | SECRET_KEY=
4 | DATABASE_URL=postgresql://starter_app_dev@localhost/starter_app
5 | S3_BUCKET_NAME=
6 | S3_ACCESS_KEY=
7 | S3_SECRET_ACCESS_KEY=
8 | OPEN_CAGE_API_KEY=
9 |
--------------------------------------------------------------------------------
/api/routers/api.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 |
3 | from api.constants import Tags
4 | from api.routers import healthcheck
5 |
6 | router = APIRouter(prefix="/api", tags=[Tags.api])
7 |
8 | router.include_router(healthcheck.router)
9 |
--------------------------------------------------------------------------------
/react-app/src/components/PageNotFound/PageNotFound.js:
--------------------------------------------------------------------------------
1 | const PageNotFound = () => {
2 | return (
3 | <>
4 |
How'd you get here? Did you have a typo?
5 | >
6 | );
7 | };
8 |
9 | export default PageNotFound;
10 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/FormTitle/FormTitle.js:
--------------------------------------------------------------------------------
1 | const FormTitle = ({ title }) => {
2 | return (
3 |
4 |
{title}
5 |
6 | );
7 | };
8 |
9 | export default FormTitle;
10 |
--------------------------------------------------------------------------------
/app/schemas/tag.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from app.schemas.config import BaseORMModeModel
4 |
5 |
6 | class MinimalTagResponse(BaseORMModeModel):
7 | id: int
8 | name: str
9 | description: str
10 | created_at: datetime
11 |
--------------------------------------------------------------------------------
/react-app/src/components/UserNotFound/UserNotFound.js:
--------------------------------------------------------------------------------
1 | const UserNotFound = () => {
2 | return (
3 |
4 |
User Not Found.
5 |
6 | );
7 | };
8 |
9 | export default UserNotFound;
10 |
--------------------------------------------------------------------------------
/react-app/src/components/CommentTreeContainer/CommentTreeContainer.js:
--------------------------------------------------------------------------------
1 | const CommentTreeContainer = () => {
2 | return (
3 | <>
4 | Comment Tree Container Placeholder
5 | >
6 | );
7 | };
8 |
9 | export default CommentTreeContainer;
10 |
--------------------------------------------------------------------------------
/app/models/posts_tag.py:
--------------------------------------------------------------------------------
1 | from app.models.db import db
2 |
3 | posts_tags = db.Table(
4 | "posts_tags",
5 | db.Model.metadata,
6 | db.Column("post_id", db.Integer, db.ForeignKey("posts.id")),
7 | db.Column("tag_id", db.Integer, db.ForeignKey("tags.id")),
8 | )
9 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | exclude =
4 | */versions/*
5 | per-file-ignores =
6 | api/models/__init__.py:F401
7 |
8 | [tool:pytest]
9 | testpaths =
10 | tests
11 | integration_tests
12 |
13 | [pycodestyle]
14 | max-line-length = 88
15 |
--------------------------------------------------------------------------------
/app/models/saved_post.py:
--------------------------------------------------------------------------------
1 | from app.models.db import db
2 |
3 | saved_posts = db.Table(
4 | "saved_posts",
5 | db.Model.metadata,
6 | db.Column("user_id", db.Integer, db.ForeignKey("users.id")),
7 | db.Column("post_id", db.Integer, db.ForeignKey("posts.id")),
8 | )
9 |
--------------------------------------------------------------------------------
/.test.env.example:
--------------------------------------------------------------------------------
1 | FLASK_APP=app
2 | FLASK_ENV=development
3 | SECRET_KEY=lkasjdf09ajsdkfljalsiorj12n3490re9485309irefvn,u90818734902139489230
4 | DATABASE_URL=postgresql://qwerkey_test_app:<>@localhost/qwerkey_test_db
5 | S3_BUCKET_NAME=
6 | S3_ACCESS_KEY=
7 | S3_SECRET_ACCESS_KEY=
8 |
--------------------------------------------------------------------------------
/app/models/saved_comment.py:
--------------------------------------------------------------------------------
1 | from app.models.db import db
2 |
3 | saved_comments = db.Table(
4 | "saved_comments",
5 | db.Model.metadata,
6 | db.Column("user_id", db.Integer, db.ForeignKey("users.id"), nullable=False),
7 | db.Column("comment_id", db.Integer, db.ForeignKey("comments.id"), nullable=False),
8 | )
9 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/Score/Score.js:
--------------------------------------------------------------------------------
1 | const Score = ({ ratings }) => {
2 | return (
3 |
4 | {ratings
5 | ? Object.values(ratings).reduce((acc, { rating }) => acc + rating, 0)
6 | : '0'}
7 |
8 | );
9 | };
10 |
11 | export default Score;
12 |
--------------------------------------------------------------------------------
/react-app/src/utils/convertFormErrors.js:
--------------------------------------------------------------------------------
1 | const convertFormErrors = (errorsObj) => {
2 | const errors = [];
3 | for (const field in errorsObj) {
4 | errors.push(
5 | `${field[0].toUpperCase() + field.slice(1)}: ${errorsObj[field]}`
6 | );
7 | }
8 | return errors;
9 | };
10 |
11 | export default convertFormErrors;
12 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/DivCard/DivCard.js:
--------------------------------------------------------------------------------
1 | const DivCard = ({ children }) => {
2 | return (
3 |
4 | {children}
5 |
6 | );
7 | };
8 |
9 | export default DivCard;
10 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/UserName/UserName.js:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 |
3 | const UserName = ({ username, link }) => {
4 | return (
5 | <>
6 |
7 | {username}
8 |
9 | >
10 | );
11 | };
12 |
13 | export default UserName;
14 |
--------------------------------------------------------------------------------
/react-app/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Qwerkey",
3 | "name": "Qwerkey",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/api/routers/healthcheck.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from sqlalchemy.orm import Session
3 |
4 | from api.constants import Tags
5 | from api.database import get_db
6 |
7 | router = APIRouter(prefix="/healthcheck", tags=[Tags.healthcheck, Tags.api])
8 |
9 |
10 | @router.get("/")
11 | def healthcheck(_db: Session = Depends(get_db)):
12 | return {"status": "ok"}
13 |
--------------------------------------------------------------------------------
/react-app/src/utils/localeDateString.js:
--------------------------------------------------------------------------------
1 | const localeDateStringOptions = {
2 | weekday: 'long',
3 | year: 'numeric',
4 | month: 'long',
5 | day: 'numeric',
6 | hour12: true,
7 | hour: '2-digit',
8 | minute: '2-digit',
9 | second: '2-digit',
10 | };
11 |
12 | export default function spreadOptionsForDateString() {
13 | return ['en-US', localeDateStringOptions];
14 | }
15 |
--------------------------------------------------------------------------------
/app/forms/create_retailer_rating.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField
3 | from wtforms.validators import DataRequired, NumberRange
4 |
5 |
6 | class CreateRetailerRating(FlaskForm):
7 | user_id = IntegerField(validators=[DataRequired()])
8 | rating = IntegerField(
9 | validators=[NumberRange(min=1, max=5, message="Must be between 1 and 5")]
10 | )
11 |
--------------------------------------------------------------------------------
/api/tests/routes/test_healthcheck.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 |
3 | from api.main import app
4 |
5 | client = TestClient(app)
6 |
7 |
8 | class TestBasicAssertions:
9 | def test__main_application__healthcheck__response_200(self):
10 | response = client.get("/api/healthcheck")
11 |
12 | assert response.status_code == 200
13 | assert response.json() == {"status": "ok"}
14 |
--------------------------------------------------------------------------------
/app/forms/create_post.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField, StringField, TextAreaField
3 | from wtforms.validators import DataRequired
4 |
5 |
6 | class CreatePost(FlaskForm):
7 | title = StringField(validators=[DataRequired()])
8 | body = TextAreaField()
9 | user_id = IntegerField(validators=[DataRequired()])
10 | community_id = IntegerField(validators=[DataRequired()])
11 |
--------------------------------------------------------------------------------
/react-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/app/schemas/meetup.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Optional
3 |
4 | from app.schemas.config import BaseORMModeModel
5 |
6 |
7 | class MinimalMeetupResponse(BaseORMModeModel):
8 | id: int
9 | name: str
10 | description: str
11 | city: str
12 | state: str
13 | lat: Optional[float]
14 | lng: Optional[float]
15 | date: datetime
16 | created_at: datetime
17 | updated_at: datetime
18 |
--------------------------------------------------------------------------------
/react-app/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | h1 {
7 | @apply text-5xl;
8 | }
9 | h2 {
10 | @apply text-4xl;
11 | }
12 | h3 {
13 | @apply text-3xl;
14 | }
15 | h4 {
16 | @apply text-2xl;
17 | }
18 | h5 {
19 | @apply text-xl;
20 | }
21 | h6 {
22 | @apply text-lg;
23 | }
24 | p {
25 | @apply text-base;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/SubmitFormButton/SubmitFormButton.js:
--------------------------------------------------------------------------------
1 | const SubmitFormButton = ({ label }) => {
2 | return (
3 |
4 |
10 |
11 | );
12 | };
13 |
14 | export default SubmitFormButton;
15 |
--------------------------------------------------------------------------------
/app/forms/create_comment.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField, StringField, TextAreaField
3 | from wtforms.validators import DataRequired
4 |
5 |
6 | class CreateComment(FlaskForm):
7 | body = TextAreaField(validators=[DataRequired()])
8 | user_id = IntegerField(validators=[DataRequired()])
9 | thread_id = IntegerField()
10 | comment_id = IntegerField()
11 | path = StringField()
12 | level = StringField()
13 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/NavButton/NavButton.js:
--------------------------------------------------------------------------------
1 | const NavButton = ({ name, onClick, children }) => {
2 | return (
3 | <>
4 |
10 | {children}
11 | >
12 | );
13 | };
14 |
15 | export default NavButton;
16 |
--------------------------------------------------------------------------------
/app/forms/create_post_rating.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField
3 | from wtforms.validators import DataRequired, ValidationError
4 |
5 |
6 | def valid_rating(form, field):
7 | if field.data not in (-1, 0, 1):
8 | raise ValidationError("Rating required.")
9 |
10 |
11 | class CreatePostRating(FlaskForm):
12 | user_id = IntegerField(validators=[DataRequired()])
13 | rating = IntegerField(validators=[valid_rating])
14 |
--------------------------------------------------------------------------------
/app/forms/create_comment_rating.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField
3 | from wtforms.validators import DataRequired, ValidationError
4 |
5 |
6 | def valid_rating(form, field):
7 | if field.data not in (-1, 0, 1):
8 | raise ValidationError("Rating required.")
9 |
10 |
11 | class CreateCommentRating(FlaskForm):
12 | user_id = IntegerField(validators=[DataRequired()])
13 | rating = IntegerField(validators=[valid_rating])
14 |
--------------------------------------------------------------------------------
/app/routes/api/posts_image.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | from app.models import Post, PostsImage, db
4 |
5 | posts_image = Blueprint("posts_image", __name__)
6 |
7 |
8 | @posts_image.route("/", methods=["DELETE"])
9 | def delete_posts_image(posts_image_id):
10 | image = PostsImage.query.get(posts_image_id)
11 | post = Post.query.get(image.post_id)
12 | db.session.delete(image)
13 | db.session.commit()
14 | return post.to_dict()
15 |
--------------------------------------------------------------------------------
/app/schemas/post.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from app.schemas.config import BaseORMModeModel
4 |
5 |
6 | class MinimalPostImageResponse(BaseORMModeModel):
7 | id: int
8 | image_url: str
9 | created_at: datetime
10 | updated_at: datetime
11 | post_id: int
12 |
13 |
14 | class MinimalPostResponse(BaseORMModeModel):
15 | id: int
16 | title: str
17 | body: str
18 | images: list[MinimalPostImageResponse]
19 | created_at: datetime
20 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/DeleteButton/DeleteButton.js:
--------------------------------------------------------------------------------
1 | const DeleteButton = ({ className, label, onClick, children }) => {
2 | return (
3 | <>
4 |
10 | {children}
11 | >
12 | );
13 | };
14 |
15 | export default DeleteButton;
16 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/EditButton/EditButton.js:
--------------------------------------------------------------------------------
1 | const EditButton = ({ className, label, children, onClick }) => {
2 | return (
3 | <>
4 |
10 | {children}
11 | >
12 | );
13 | };
14 |
15 | export default EditButton;
16 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/SaveButton/SaveButton.js:
--------------------------------------------------------------------------------
1 | import { BookmarkBorder, Bookmark } from '@mui/icons-material';
2 |
3 | const SaveButton = ({ save, isSaved }) => {
4 | return (
5 | <>
6 |
12 | >
13 | );
14 | };
15 |
16 | export default SaveButton;
17 |
--------------------------------------------------------------------------------
/react-app/.babelrc.js:
--------------------------------------------------------------------------------
1 | const plugins = [
2 | [
3 | 'babel-plugin-import',
4 | {
5 | libraryName: '@material-ui/core',
6 | libraryDirectory: 'esm',
7 | camel2DashComponentName: false,
8 | },
9 | 'core',
10 | ],
11 | [
12 | 'babel-plugin-import',
13 | {
14 | libraryName: '@material-ui/icons',
15 | libraryDirectory: 'esm',
16 | camel2DashComponentName: false,
17 | },
18 | 'icons',
19 | ],
20 | ];
21 |
22 | module.exports = { plugins };
23 |
--------------------------------------------------------------------------------
/app/forms/create_retailer.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField, StringField, TextAreaField
3 | from wtforms.validators import DataRequired
4 |
5 |
6 | class CreateRetailer(FlaskForm):
7 | user_id = IntegerField(validators=[DataRequired()])
8 | name = StringField(validators=[DataRequired()])
9 | description = TextAreaField(validators=[DataRequired()])
10 | city = StringField(validators=[DataRequired()])
11 | state = StringField(validators=[DataRequired()])
12 |
--------------------------------------------------------------------------------
/api/README.org:
--------------------------------------------------------------------------------
1 | #+title: FastAPI Application
2 |
3 | An endeavor to migrate the existing Flask application over to FastAPI.
4 |
5 | ** Install Dependencies and Enter Pipenv
6 | #+begin_src shell
7 | % pipenv install --dev
8 | % pipenv shell
9 | #+end_src
10 |
11 | ** Running the application
12 | *** From the root of the project
13 | #+begin_src shell
14 | % uvicorn api.main:app --reload
15 | #+end_src
16 | *** From within the =api= directory
17 | #+begin_src shell
18 | % uvicorn main:app --reload
19 | #+end_src
20 |
--------------------------------------------------------------------------------
/react-app/src/components/EditCommentForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import EditCommentForm from './EditCommentForm';
3 |
4 | const EditCommentModal = ({ showEditModal, setShowEditModal }) => {
5 | return (
6 | <>
7 | {showEditModal && (
8 | setShowEditModal(false)}>
9 |
10 |
11 | )}
12 | >
13 | );
14 | };
15 |
16 | export default EditCommentModal;
17 |
--------------------------------------------------------------------------------
/app/seeds/posts.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import Post, db
4 |
5 |
6 | def seed_posts():
7 | new_posts = []
8 | with open("./app/seeds/posts.json") as f:
9 | data = json.load(f)
10 | for post in data:
11 | new_post = Post(**post)
12 | new_posts.append(new_post)
13 |
14 | db.session.add_all(new_posts)
15 | db.session.commit()
16 |
17 |
18 | def undo_posts():
19 | db.session.execute("TRUNCATE posts RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/react-app/src/components/EditPostForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import EditPostForm from './EditPostForm';
3 |
4 | const EditPostModal = ({ setShowEditModal, showEditModal, postId }) => {
5 | return (
6 | <>
7 | {showEditModal && (
8 | setShowEditModal(false)}>
9 |
10 |
11 | )}
12 | >
13 | );
14 | };
15 |
16 | export default EditPostModal;
17 |
--------------------------------------------------------------------------------
/react-app/src/context/CommentContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from 'react';
2 |
3 | const CommentContext = createContext();
4 |
5 | export const useCommentContext = () => useContext(CommentContext);
6 |
7 | const CommentProvider = ({ children }) => {
8 | const [comment, setComment] = useState();
9 |
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | };
16 |
17 | export default CommentProvider;
18 |
--------------------------------------------------------------------------------
/app/schemas/responses.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class BaseResponse(BaseModel):
5 | status: int = 200
6 |
7 |
8 | class BaseErrorsResponse(BaseResponse):
9 | errors: list[str]
10 | status: int = 400
11 |
12 |
13 | class UnauthenticatedErrorsResponse(BaseResponse):
14 | errors: list[str] = ["Unauthenticated"]
15 | status: int = 401
16 | message: str = "Not Authenticated"
17 |
18 |
19 | class LogoutResponse(BaseResponse):
20 | status: int = 200
21 | message: str = "User logged out"
22 |
--------------------------------------------------------------------------------
/app/seeds/threads.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import Thread, db
4 |
5 |
6 | def seed_threads():
7 | threads = []
8 | with open("./app/seeds/threads.json") as f:
9 | data = json.load(f)
10 | for thread in data:
11 | new_thread = Thread(**thread)
12 | threads.append(new_thread)
13 |
14 | db.session.add_all(threads)
15 | db.session.commit()
16 |
17 |
18 | def undo_threads():
19 | db.session.execute("TRUNCATE threads RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/app/seeds/meetups.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import Meetup, db
4 |
5 |
6 | def seed_meetups():
7 | new_meetups = []
8 | with open("./app/seeds/meetups.json") as f:
9 | data = json.load(f)
10 | for meetup in data:
11 | new_meetup = Meetup(**meetup)
12 | new_meetups.append(new_meetup)
13 |
14 | db.session.add_all(new_meetups)
15 | db.session.commit()
16 |
17 |
18 | def undo_meetups():
19 | db.session.execute("TRUNCATE meetups RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/react-app/src/components/UserCard/UserCard.js:
--------------------------------------------------------------------------------
1 | import options from '../../utils/localeDateString';
2 |
3 | const UserCard = ({ user }) => {
4 | return (
5 |
6 |
{user.username}
7 |
8 | Account created on{' '}
9 | {new Date(user.created_at).toLocaleString(...options())}
10 |
11 |
12 | );
13 | };
14 |
15 | export default UserCard;
16 |
--------------------------------------------------------------------------------
/react-app/src/components/LoginForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import { useAuthContext } from '../../context/AuthContext';
3 | import LoginForm from './LoginForm';
4 |
5 | const LoginModal = () => {
6 | const { showLoginModal, setShowLoginModal } = useAuthContext();
7 |
8 | return (
9 | <>
10 | {showLoginModal && (
11 | setShowLoginModal(false)}>
12 |
13 |
14 | )}
15 | >
16 | );
17 | };
18 |
19 | export default LoginModal;
20 |
--------------------------------------------------------------------------------
/react-app/src/components/EditRetailerRatingForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import EditRetailerRatingForm from './EditRetailerRatingForm';
3 |
4 | const EditRetailerRatingModal = ({ showEditModal, setShowEditModal }) => {
5 | return (
6 | <>
7 | {showEditModal && (
8 | setShowEditModal(false)}>
9 |
10 |
11 | )}
12 | >
13 | );
14 | };
15 |
16 | export default EditRetailerRatingModal;
17 |
--------------------------------------------------------------------------------
/app/seeds/comments.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import Comment, db
4 |
5 |
6 | def seed_comments():
7 | new_comments = []
8 | with open("./app/seeds/comments.json") as f:
9 | data = json.load(f)
10 | for comment in data:
11 | new_comment = Comment(**comment)
12 | new_comments.append(new_comment)
13 |
14 | db.session.add_all(new_comments)
15 | db.session.commit()
16 |
17 |
18 | def undo_comments():
19 | db.session.execute("TRUNCATE comments RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/react-app/src/components/SignUpForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import { useAuthContext } from '../../context/AuthContext';
3 | import SignUpForm from './SignUpForm';
4 |
5 | const SignUpModal = () => {
6 | const { showSignUpModal, setShowSignUpModal } = useAuthContext();
7 |
8 | return (
9 | <>
10 | {showSignUpModal && (
11 | setShowSignUpModal(false)}>
12 |
13 |
14 | )}
15 | >
16 | );
17 | };
18 |
19 | export default SignUpModal;
20 |
--------------------------------------------------------------------------------
/app/seeds/post_images.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import PostsImage, db
4 |
5 |
6 | def seed_post_images():
7 | new_images = []
8 | with open("./app/seeds/post_images.json") as f:
9 | data = json.load(f)
10 | for image in data:
11 | new_image = PostsImage(**image)
12 | new_images.append(new_image)
13 |
14 | db.session.add_all(new_images)
15 | db.session.commit()
16 |
17 |
18 | def undo_post_images():
19 | db.session.execute("TRUNCATE posts_images RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/app/routes/api/lat_long.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from flask import Blueprint, request
3 |
4 | from app.config import Config
5 |
6 | lat_long = Blueprint("lat_long", __name__)
7 |
8 |
9 | @lat_long.route("/")
10 | def get_lat_long():
11 | city = request.args.get("city")
12 | state = request.args.get("state")
13 | response = requests.get(
14 | "https://api.opencagedata.com/geocode/v1/json?"
15 | + f"key={Config.OPEN_CAGE_API_KEY}"
16 | + f"&q={city},{state},USA"
17 | )
18 | data = response.json()
19 | return data["results"][0]["geometry"]
20 |
--------------------------------------------------------------------------------
/app/seeds/retailers.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import Retailer, db
4 |
5 |
6 | def seed_retailers():
7 | new_retailers = []
8 | with open("./app/seeds/retailers.json") as f:
9 | data = json.load(f)
10 | for retailer in data:
11 | new_retailer = Retailer(**retailer)
12 | new_retailers.append(new_retailer)
13 |
14 | db.session.add_all(new_retailers)
15 | db.session.commit()
16 |
17 |
18 | def undo_retailers():
19 | db.session.execute("TRUNCATE retailers RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/app/seeds/post_ratings.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import PostRating, db
4 |
5 |
6 | def seed_post_ratings():
7 | new_ratings = []
8 | with open("./app/seeds/post_ratings.json") as f:
9 | data = json.load(f)
10 | for rating in data:
11 | new_rating = PostRating(**rating)
12 | new_ratings.append(new_rating)
13 |
14 | db.session.add_all(new_ratings)
15 | db.session.commit()
16 |
17 |
18 | def undo_post_ratings():
19 | db.session.execute("TRUNCATE post_ratings RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/react-app/src/components/EditMeetupForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import EditMeetupForm from './EditMeetupForm';
3 |
4 | const EditMeetupModal = ({ setShowEditModal, showEditModal, meetupId }) => {
5 | return (
6 | <>
7 | {showEditModal && (
8 | setShowEditModal(false)}>
9 |
13 |
14 | )}
15 | >
16 | );
17 | };
18 |
19 | export default EditMeetupModal;
20 |
--------------------------------------------------------------------------------
/app/seeds/communities.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import Community, db
4 |
5 |
6 | def seed_communities():
7 | new_communities = []
8 | with open("./app/seeds/communities.json") as f:
9 | data = json.load(f)
10 | for community in data:
11 | new_community = Community(**community)
12 | new_communities.append(new_community)
13 |
14 | db.session.add_all(new_communities)
15 | db.session.commit()
16 |
17 |
18 | def undo_communities():
19 | db.session.execute("TRUNCATE communities RESTART IDENTITY CASCADE")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/app/seeds/comment_ratings.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import CommentRating, db
4 |
5 |
6 | def seed_comment_ratings():
7 | new_ratings = []
8 | with open("./app/seeds/comment_ratings.json") as f:
9 | data = json.load(f)
10 | for rating in data:
11 | new_rating = CommentRating(**rating)
12 | new_ratings.append(new_rating)
13 |
14 | db.session.add_all(new_ratings)
15 | db.session.commit()
16 |
17 |
18 | def undo_comment_ratings():
19 | db.session.execute("TRUNCATE comment_ratings RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/app/seeds/retailer_ratings.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import RetailerRating, db
4 |
5 |
6 | def seed_retailer_ratings():
7 | new_ratings = []
8 | with open("./app/seeds/retailer_ratings.json") as f:
9 | data = json.load(f)
10 | for rating in data:
11 | new_rating = RetailerRating(**rating)
12 | new_ratings.append(new_rating)
13 |
14 | db.session.add_all(new_ratings)
15 | db.session.commit()
16 |
17 |
18 | def undo_retailer_ratings():
19 | db.session.execute("TRUNCATE retailer_ratings RESTART IDENTITY CASCADE;")
20 | db.session.commit()
21 |
--------------------------------------------------------------------------------
/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/react-app/src/context/DarkModeContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from 'react';
2 |
3 | const DarkModeContext = createContext();
4 |
5 | export const useDarkModeContext = () => useContext(DarkModeContext);
6 |
7 | const DarkModeProvider = ({ children }) => {
8 | const [isDarkMode, setIsDarkMode] = useState(
9 | localStorage.getItem('isDarkMode') === 'true' || false
10 | );
11 |
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default DarkModeProvider;
20 |
--------------------------------------------------------------------------------
/app/forms/create_community.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import StringField, TextAreaField
3 | from wtforms.validators import DataRequired, ValidationError
4 |
5 | from app.models import Community
6 |
7 |
8 | def community_exists(form, field):
9 | community = Community.query.filter(Community.name == field.data).first()
10 | if community:
11 | raise ValidationError("Community name already exists.")
12 |
13 |
14 | class CreateCommunity(FlaskForm):
15 | name = StringField(validators=[DataRequired(), community_exists])
16 | description = TextAreaField(validators=[DataRequired()])
17 |
--------------------------------------------------------------------------------
/react-app/src/context/CreatePostContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useState, useContext } from 'react';
2 |
3 | const CreatePostContext = createContext();
4 |
5 | export const useCreatePostContext = () => useContext(CreatePostContext);
6 |
7 | const CreatePostProvider = ({ children }) => {
8 | const [showCreatePostModal, setShowCreatePostModal] = useState(false);
9 |
10 | return (
11 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default CreatePostProvider;
20 |
--------------------------------------------------------------------------------
/api/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 |
4 | from api.routers import api
5 | from api.settings import settings
6 |
7 | app = FastAPI()
8 |
9 | origins = [
10 | "http://localhost:3000",
11 | ]
12 |
13 | if settings.is_dev:
14 | app.add_middleware(
15 | CORSMiddleware,
16 | allow_origins=origins,
17 | allow_credentials=True,
18 | allow_methods=["*"],
19 | allow_headers=["*"],
20 | )
21 |
22 | app.include_router(api.router)
23 |
24 |
25 | @app.get("/")
26 | async def root():
27 | return {"message": "Hello World"}
28 |
--------------------------------------------------------------------------------
/react-app/src/context/RetailerRatingContext.js:
--------------------------------------------------------------------------------
1 | import { useContext, useState, createContext } from 'react';
2 |
3 | const RetailerRatingContext = createContext();
4 |
5 | export const useRetailerRatingContext = () => useContext(RetailerRatingContext);
6 |
7 | const RetailerRatingProvider = ({ children }) => {
8 | const [retailerRating, setRetailerRating] = useState();
9 |
10 | return (
11 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default RetailerRatingProvider;
20 |
--------------------------------------------------------------------------------
/react-app/src/components/Community/Community.js:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 |
3 | const Community = ({ community }) => {
4 | return (
5 |
6 |
7 |
8 | {community.name}
9 |
10 |
11 |
{community.description}
12 |
13 | );
14 | };
15 |
16 | export default Community;
17 |
--------------------------------------------------------------------------------
/react-app/src/components/CreatePostForm/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../context/ModalContext';
2 | import { useCreatePostContext } from '../../context/CreatePostContext';
3 | import CreatePostForm from './CreatePostForm';
4 |
5 | const CreatePostModal = () => {
6 | const { showCreatePostModal, setShowCreatePostModal } =
7 | useCreatePostContext();
8 |
9 | return (
10 | <>
11 | {showCreatePostModal && (
12 | setShowCreatePostModal(false)}>
13 |
14 |
15 | )}
16 | >
17 | );
18 | };
19 |
20 | export default CreatePostModal;
21 |
--------------------------------------------------------------------------------
/react-app/src/context/SearchContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from 'react';
2 |
3 | const SearchContext = createContext();
4 |
5 | export const useSearchContext = () => useContext(SearchContext);
6 |
7 | const SearchProvider = ({ children }) => {
8 | const [searchInput, setSearchInput] = useState('');
9 | const [searched, setSearched] = useState(false);
10 |
11 | return (
12 |
15 | {children}
16 |
17 | );
18 | };
19 |
20 | export default SearchProvider;
21 |
--------------------------------------------------------------------------------
/api/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade() -> None:
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade() -> None:
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/app/forms/__init__.py:
--------------------------------------------------------------------------------
1 | from app.forms.create_comment import CreateComment # noqa
2 | from app.forms.create_comment_rating import CreateCommentRating # noqa
3 | from app.forms.create_community import CreateCommunity # noqa
4 | from app.forms.create_meetup import CreateMeetup # noqa
5 | from app.forms.create_post import CreatePost # noqa
6 | from app.forms.create_post_rating import CreatePostRating # noqa
7 | from app.forms.create_retailer import CreateRetailer # noqa
8 | from app.forms.create_retailer_rating import CreateRetailerRating # noqa
9 | from app.forms.login import LoginForm # noqa
10 | from app.forms.signup import SignUpForm # noqa
11 |
--------------------------------------------------------------------------------
/docker-compose.org:
--------------------------------------------------------------------------------
1 | #+title: Docker-Compose
2 |
3 | ** Running the Images
4 | #+begin_src bash
5 | docker compose up
6 | #+end_src
7 | ** Set up Postgres Container
8 | #+begin_src bash
9 | docker compose exec postgres sh
10 |
11 | psql -U postgres
12 | #+end_src
13 |
14 | #+begin_src sql
15 | create user qwerkey_app with password 'notsecret';
16 |
17 | create database qwerkey_app with owner qwerkey_app;
18 | #+end_src
19 | ** Apply Migrations
20 | #+begin_src bash
21 | docker compose exec server sh
22 | flask db upgrade
23 | #+end_src
24 | ** Seed the database
25 | #+begin_src bash
26 | docker compose exec server sh
27 | flask seed all
28 | #+end_src
29 |
--------------------------------------------------------------------------------
/react-app/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 |
3 | import session from './session';
4 | import posts from './posts';
5 | import comments from './comments';
6 | import retailers from './retailers';
7 | import search from './search';
8 | import communities from './communities';
9 | import sidebar from './sidebar';
10 | import users from './users';
11 | import meetups from './meetups';
12 |
13 | export const store = configureStore({
14 | reducer: {
15 | session,
16 | posts,
17 | comments,
18 | retailers,
19 | search,
20 | communities,
21 | sidebar,
22 | users,
23 | meetups,
24 | },
25 | });
26 |
--------------------------------------------------------------------------------
/.github/workflows/test-frontend.yml:
--------------------------------------------------------------------------------
1 | name: Test Frontend
2 |
3 | on:
4 | pull_request:
5 | branches: [staging]
6 | paths:
7 | - "react-app/**"
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | defaults:
14 | run:
15 | working-directory: react-app
16 |
17 | steps:
18 | - uses: actions/checkout@v2
19 | - name: Use Node.js 14
20 | uses: actions/setup-node@v2
21 | with:
22 | node-version: 18
23 | cache: "npm"
24 | cache-dependency-path: react-app/package-lock.json
25 | # Not my best idea...
26 | - run: npm install --force
27 | - run: npm test
28 |
--------------------------------------------------------------------------------
/.github/workflows/pre-commit.yml:
--------------------------------------------------------------------------------
1 | name: "Pre-Commit Check"
2 | on:
3 | pull_request:
4 | types:
5 | - opened
6 | - reopened
7 | - synchronize
8 | env:
9 | PYTHON_VERSION: 3.11
10 | jobs:
11 | pre-commit:
12 | runs-on: ubuntu-latest
13 | timeout-minutes: 20
14 | steps:
15 | - uses: actions/cache@v2
16 | with:
17 | path: ~/.cache/pre-commit
18 | key: pre-commit-${{ env.PYTHON_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }}
19 | - uses: actions/checkout@v3
20 | - uses: actions/setup-python@v4
21 | with:
22 | python-version: "3.11"
23 | - uses: pre-commit/action@v2.0.2
24 |
--------------------------------------------------------------------------------
/app/models/thread.py:
--------------------------------------------------------------------------------
1 | from app.models.db import db
2 |
3 |
4 | class Thread(db.Model):
5 | __tablename__ = "threads"
6 |
7 | id = db.Column(db.Integer, primary_key=True)
8 | post_id = db.Column(db.Integer, db.ForeignKey("posts.id"), nullable=False)
9 |
10 | comments = db.relationship("Comment", back_populates="thread")
11 | post = db.relationship("Post", back_populates="threads")
12 |
13 | def to_dict(self):
14 | return {
15 | "id": self.id,
16 | "comments": {
17 | comment.id: comment.to_simple_dict() for comment in self.comments
18 | },
19 | "post_id": self.post_id,
20 | }
21 |
--------------------------------------------------------------------------------
/react-app/src/context/CollapsedSidebarContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from 'react';
2 |
3 | const CollapsedSidebarContext = createContext();
4 |
5 | export const useCollapsedSidebarContext = () =>
6 | useContext(CollapsedSidebarContext);
7 |
8 | const CollapsedSidebarProvider = ({ children }) => {
9 | const [showCollapsedSidebar, setShowCollapsedSidebar] = useState(false);
10 |
11 | return (
12 |
15 | {children}
16 |
17 | );
18 | };
19 |
20 | export default CollapsedSidebarProvider;
21 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build Test
2 |
3 | on:
4 | pull_request:
5 | branches: [staging]
6 | paths:
7 | - "react-app/**"
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | defaults:
14 | run:
15 | working-directory: react-app
16 |
17 | steps:
18 | - uses: actions/checkout@v2
19 | - name: Use Node.js 18
20 | uses: actions/setup-node@v2
21 | with:
22 | node-version: 18
23 | cache: "npm"
24 | cache-dependency-path: react-app/package-lock.json
25 | # This isn't my best idea...
26 | - run: npm install --force
27 | - run: npm run build --if-present
28 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | volumes:
2 | postgres-data: {}
3 |
4 | services:
5 | server:
6 | build: .
7 | ports:
8 | - "8000:8000"
9 | volumes:
10 | - .:/usr/src/app
11 | environment:
12 | PORT: 8000
13 | SECRET_KEY: 481284c381c6e4b9a520739cfbb74fa755b9520467187a35cac9468e82fe603c
14 | DATABASE_URL: postgresql://qwerkey_app:notsecret@postgres:5432/qwerkey_app
15 | postgres:
16 | image: "postgres:alpine"
17 | environment:
18 | POSTGRES_DB: qwerkey_app
19 | POSTGRES_USER: qwerkey_app
20 | POSTGRES_PASSWORD: notsecret
21 | ports:
22 | - "5432:5432"
23 | volumes:
24 | - postgres-data:/var/lib/postgres/data
25 |
--------------------------------------------------------------------------------
/app/schemas/comment.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Optional
3 |
4 | from app.schemas.config import BaseORMModeModel
5 |
6 |
7 | class CommentRatingResponse(BaseORMModeModel):
8 | id: int
9 | comment_id: int
10 | rating: int
11 |
12 |
13 | class MinimalCommentResponse(BaseORMModeModel):
14 | id: int
15 | body: str
16 | comment_id: Optional[int]
17 |
18 |
19 | class SearchCommentResponse(MinimalCommentResponse):
20 | ratings: list[CommentRatingResponse]
21 | created_at: datetime
22 |
23 |
24 | class FullCommentResponse(SearchCommentResponse):
25 | thread_id: int
26 | path: str
27 | level: int
28 | updated_at: datetime
29 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18 AS build-stage
2 |
3 | WORKDIR /react-app
4 | COPY react-app/package.json react-app/package-lock.json ./
5 |
6 | RUN npm install
7 |
8 | COPY react-app/ ./
9 | RUN npm run build
10 |
11 | FROM python:3.11
12 |
13 | # Setup Flask environment
14 | ENV FLASK_APP=app
15 | ENV FLASK_ENV=production
16 | ENV SQLALCHEMY_ECHO=True
17 |
18 | EXPOSE 8000
19 |
20 | WORKDIR /var/www
21 | COPY requirements.txt .
22 |
23 | # Install Python Dependencies
24 | RUN pip install -r requirements.txt
25 | RUN pip install psycopg2
26 |
27 | COPY . .
28 | COPY --from=build-stage /react-app/build/* app/static/
29 |
30 | # Run flask environment
31 | CMD gunicorn --bind 0.0.0.0:$PORT 'app:create_app()'
32 |
--------------------------------------------------------------------------------
/react-app/src/components/CommentThreadContainer/CommentThreadContainer.js:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 |
3 | import { session, comments as commentsSelectors } from '../../store/selectors';
4 | import Comment from '../Comment';
5 |
6 | const CommentsContainer = () => {
7 | const user = useSelector(session.user());
8 | const comments = useSelector(commentsSelectors.all());
9 |
10 | return (
11 | <>
12 | {Object.values(comments).map((comment) => (
13 |
18 | ))}
19 | >
20 | );
21 | };
22 |
23 | export default CommentsContainer;
24 |
--------------------------------------------------------------------------------
/react-app/src/store/api.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie';
2 |
3 | const defaultOptions = { headers: {} };
4 |
5 | export default function api(url, options = defaultOptions, isFormData = false) {
6 | if (!isFormData) {
7 | return fetch(url, {
8 | method: 'GET',
9 | ...options,
10 | headers: {
11 | 'Content-Type': 'application/json',
12 | 'X-CSRFToken': Cookies.get('csrf_token'),
13 | ...options.headers,
14 | },
15 | });
16 | } else {
17 | return fetch(url, {
18 | method: 'GET',
19 | ...options,
20 | headers: {
21 | 'X-CSRFToken': Cookies.get('csrf_token'),
22 | ...options.headers,
23 | },
24 | });
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/DeleteConfirmation/index.js:
--------------------------------------------------------------------------------
1 | import { Modal } from '../../../context/ModalContext';
2 | import DeleteConfirmation from './DeleteConfirmation';
3 |
4 | const DeleteConfirmationModal = ({
5 | showDeleteModal,
6 | setShowDeleteModal,
7 | id,
8 | extraId,
9 | type,
10 | }) => {
11 | return (
12 | <>
13 | {showDeleteModal && (
14 | setShowDeleteModal(false)}>
15 |
21 |
22 | )}
23 | >
24 | );
25 | };
26 |
27 | export default DeleteConfirmationModal;
28 |
--------------------------------------------------------------------------------
/react-app/src/components/LogoutButton/LogoutButton.js:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 |
3 | import { logout } from '../../store/session';
4 | import { useAuthContext } from '../../context/AuthContext';
5 |
6 | const LogoutButton = () => {
7 | const dispatch = useDispatch();
8 | const { setAuthenticated } = useAuthContext();
9 |
10 | const onLogout = async () => {
11 | await dispatch(logout());
12 | setAuthenticated(false);
13 | };
14 |
15 | return (
16 |
22 | );
23 | };
24 |
25 | export default LogoutButton;
26 |
--------------------------------------------------------------------------------
/app/models/tag.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 |
5 |
6 | class Tag(db.Model):
7 | __tablename__ = "tags"
8 |
9 | id = db.Column(db.Integer, primary_key=True)
10 | name = db.Column(db.String(50), nullable=False)
11 | description = db.Column(db.String(100), nullable=True)
12 | created_at = db.Column(
13 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
14 | )
15 | updated_at = db.Column(
16 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
17 | )
18 |
19 | def to_dict(self):
20 | return {
21 | "id": self.id,
22 | "name": self.name,
23 | "description": self.description,
24 | "created_at": self.created_at,
25 | }
26 |
--------------------------------------------------------------------------------
/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | -i https://pypi.org/simple
2 | anyio==3.7.0 ; python_version >= '3.7'
3 | astroid==2.15.5 ; python_full_version >= '3.7.2'
4 | autopep8==2.0.2
5 | certifi==2020.12.5
6 | dill==0.3.6 ; python_version >= '3.11'
7 | h11==0.14.0 ; python_version >= '3.7'
8 | httpcore==0.17.2 ; python_version >= '3.7'
9 | httpx==0.24.1
10 | idna==2.10
11 | isort==5.12.0 ; python_full_version >= '3.8.0'
12 | lazy-object-proxy==1.9.0 ; python_version >= '3.7'
13 | mccabe==0.7.0 ; python_version >= '3.6'
14 | platformdirs==3.8.0 ; python_version >= '3.7'
15 | psycopg2-binary==2.9.6
16 | pycodestyle==2.10.0 ; python_version >= '3.6'
17 | pylint==2.17.4
18 | sniffio==1.3.0 ; python_version >= '3.7'
19 | tomlkit==0.11.8 ; python_version >= '3.7'
20 | wrapt==1.15.0 ; python_version >= '3.11'
21 |
--------------------------------------------------------------------------------
/app/schemas/retailer.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from app.schemas.config import BaseORMModeModel
4 |
5 |
6 | class MinimalRetailerRatingResponse(BaseORMModeModel):
7 | id: int
8 | rating: int
9 |
10 |
11 | class FullRetailerRatingResponse(MinimalRetailerRatingResponse):
12 | retailer_id: int
13 | created_at: datetime
14 |
15 |
16 | class MinimalRetailerImageResponse:
17 | id: int
18 | retailer_id: int
19 | image_url: str
20 | created_at: datetime
21 |
22 |
23 | class MinimalRetailerResponse(BaseORMModeModel):
24 | id: int
25 | name: str
26 | description: str
27 | city: str
28 | state: str
29 | created_at: datetime
30 |
31 |
32 | class FullRetailerResponse(MinimalRetailerResponse):
33 | lat: float
34 | lng: float
35 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/FormErrors/FormErrors.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | const FormErrors = ({ errors }) => {
4 | const [formattedErrors, setFormattedErrors] = useState([]);
5 |
6 | useEffect(() => {
7 | const result = errors.map((error) => {
8 | const arr = error.split(':')[0].trim();
9 | return arr[0].toUpperCase() + arr.slice(1);
10 | });
11 | setFormattedErrors(result);
12 | }, [errors]);
13 |
14 | return (
15 | <>
16 | {errors.length > 0 && (
17 |
18 |
The following fields are required:
19 | {formattedErrors.join(', ')}
20 |
21 | )}
22 | >
23 | );
24 | };
25 |
26 | export default FormErrors;
27 |
--------------------------------------------------------------------------------
/app/seeds/users.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from app.models import User, db
4 |
5 |
6 | # Adds a demo user, you can add other users here if you want
7 | def seed_users():
8 | new_users = []
9 | with open("./app/seeds/users.json") as f:
10 | data = json.load(f)
11 | for user in data:
12 | new_user = User(**user)
13 | new_users.append(new_user)
14 |
15 | db.session.add_all(new_users)
16 | db.session.commit()
17 |
18 |
19 | # Uses a raw SQL query to TRUNCATE the users table.
20 | # SQLAlchemy doesn't have a built in function to do this
21 | # TRUNCATE Removes all the data from the table, and resets
22 | # the auto incrementing primary key
23 | def undo_users():
24 | db.session.execute("TRUNCATE users RESTART IDENTITY CASCADE;")
25 | db.session.commit()
26 |
--------------------------------------------------------------------------------
/react-app/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { Provider } from 'react-redux';
4 | import { BrowserRouter } from 'react-router-dom';
5 |
6 | import './index.css';
7 | import App from './App';
8 | import { store } from './store';
9 | import ContextProvider from './context';
10 |
11 | if (process.env.NODE_ENV !== 'production') {
12 | window.store = store;
13 | }
14 |
15 | const Root = () => (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 |
25 | const root = createRoot(document.getElementById('root'));
26 |
27 | root.render(
28 |
29 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/react-app/src/components/Map/Map.js:
--------------------------------------------------------------------------------
1 | import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
2 |
3 | const Map = ({ long, lat }) => {
4 | return (
5 |
6 |
11 |
15 |
16 |
17 | A pretty CSS3 popup.
Easily customizable.
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default Map;
26 |
--------------------------------------------------------------------------------
/react-app/src/context/AuthContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useState, useContext } from 'react';
2 |
3 | const AuthContext = createContext();
4 |
5 | export const useAuthContext = () => useContext(AuthContext);
6 |
7 | const AuthProvider = ({ children }) => {
8 | const [showLoginModal, setShowLoginModal] = useState(false);
9 | const [showSignUpModal, setShowSignUpModal] = useState(false);
10 | const [authenticated, setAuthenticated] = useState(false);
11 |
12 | return (
13 |
23 | {children}
24 |
25 | );
26 | };
27 |
28 | export default AuthProvider;
29 |
--------------------------------------------------------------------------------
/api/models/user.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import String
2 | from sqlalchemy.orm import Mapped, mapped_column
3 | from werkzeug.security import check_password_hash, generate_password_hash
4 |
5 | from api.database import Base
6 |
7 |
8 | class User(Base):
9 | __tablename__ = "users"
10 |
11 | username: Mapped[str] = mapped_column(String(40), unique=True)
12 | email: Mapped[str] = mapped_column(String(255), unique=True)
13 | hashed_password: Mapped[str] = mapped_column(String(255))
14 |
15 | @property
16 | def password(self) -> str:
17 | return self.hashed_password
18 |
19 | @password.setter
20 | def password(self, password: str):
21 | self.hashed_password = generate_password_hash(password)
22 |
23 | def check_password(self, password: str) -> bool:
24 | return check_password_hash(self.password, password)
25 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | default_language_version:
2 | python: python3.11
3 | node: 18.16.0
4 | repos:
5 | - repo: https://github.com/psf/black
6 | rev: 23.3.0
7 | hooks:
8 | - id: black
9 | language_version: python
10 | types: [python]
11 | exclude: /versions/
12 | - repo: https://github.com/PyCQA/flake8
13 | rev: 6.0.0
14 | hooks:
15 | - id: flake8
16 | - repo: https://github.com/PyCQA/isort
17 | rev: 5.12.0
18 | hooks:
19 | - id: isort
20 | - repo: https://github.com/pre-commit/mirrors-prettier
21 | rev: "v2.7.1"
22 | hooks:
23 | - id: prettier
24 | - repo: https://github.com/pre-commit/pre-commit-hooks
25 | rev: v4.1.0
26 | hooks:
27 | - id: no-commit-to-branch
28 | args:
29 | - --branch
30 | - staging
31 | - --branch
32 | - main
33 |
--------------------------------------------------------------------------------
/app/forms/create_meetup.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField, StringField, TextAreaField
3 | from wtforms.validators import DataRequired, ValidationError
4 |
5 |
6 | def city_not_default(form, field):
7 | if field.data == "City...":
8 | raise ValidationError("City is required.")
9 |
10 |
11 | def state_not_default(form, field):
12 | if field.data == "State...":
13 | raise ValidationError("State is required.")
14 |
15 |
16 | class CreateMeetup(FlaskForm):
17 | user_id = IntegerField(validators=[DataRequired()])
18 | name = StringField(validators=[DataRequired()])
19 | description = TextAreaField(validators=[DataRequired()])
20 | city = StringField(validators=[DataRequired(), city_not_default])
21 | state = StringField(validators=[DataRequired(), state_not_default])
22 | date = StringField(validators=[DataRequired()])
23 |
--------------------------------------------------------------------------------
/app/models/post_image.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 |
5 |
6 | class PostsImage(db.Model):
7 | __tablename__ = "posts_images"
8 |
9 | id = db.Column(db.Integer, primary_key=True)
10 | post_id = db.Column(db.Integer, db.ForeignKey("posts.id"), nullable=False)
11 | image_url = db.Column(db.String, nullable=False)
12 | created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
13 | updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
14 |
15 | post = db.relationship("Post", back_populates="images")
16 |
17 | def to_dict(self):
18 | return {
19 | "id": self.id,
20 | "post_id": self.post_id,
21 | "image_url": self.image_url,
22 | "created_at": self.created_at,
23 | "updated_at": self.updated_at,
24 | "post": self.post.to_dict(),
25 | }
26 |
--------------------------------------------------------------------------------
/.github/workflows/pytest.yml:
--------------------------------------------------------------------------------
1 | name: Pytest
2 |
3 | on:
4 | pull_request:
5 | branches: [staging]
6 | paths:
7 | - "app/**"
8 | - "api/**"
9 | - "Pipfile*"
10 | - "*requirements.txt"
11 |
12 | jobs:
13 | pytest:
14 | runs-on: ubuntu-latest
15 | env:
16 | SECRET_KEY: ${{ secrets.SECRET_KEY }}
17 | S3_BUCKET_NAME: test
18 | S3_ACCESS_KEY: test
19 | S3_SECRET_ACCESS_KEY: test
20 | DATABASE_URL: "postgresql://test:test@localhost/test"
21 | OPEN_CAGE_API_KEY: test
22 |
23 | steps:
24 | - uses: actions/checkout@v3
25 | - uses: actions/setup-python@v3
26 | with:
27 | python-version: "3.11"
28 | - name: Install Pipenv
29 | run: pip install pipenv
30 | - name: Install dependencies
31 | run: pipenv install --deploy --dev
32 | - name: Run pytest
33 | run: pipenv run pytest
34 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 | from flask_cors import CORS
3 | from flask_login import LoginManager
4 | from flask_migrate import Migrate
5 | from flask_wtf.csrf import CSRFProtect
6 |
7 | from app.config import Config
8 | from app.models import User, db
9 | from app.routes import routes
10 | from app.seeds import seed_commands
11 |
12 |
13 | def create_app(testing=False):
14 | app = Flask(__name__)
15 |
16 | login = LoginManager(app)
17 | login.login_view = "api.auth.unauthorized"
18 |
19 | config = Config(testing)
20 | app.config.from_object(config)
21 |
22 | @login.user_loader
23 | def load_user(id):
24 | return User.query.get(int(id))
25 |
26 | app.cli.add_command(seed_commands)
27 |
28 | db.init_app(app)
29 | Migrate(app, db)
30 | CORS(app)
31 | CSRFProtect(app)
32 |
33 | app.register_blueprint(routes)
34 |
35 | return app
36 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerRatingsContainer/RetailerRatingsContainer.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector } from 'react-redux';
3 |
4 | import { retailers } from '../../store/selectors';
5 | import RetailerRating from '../RetailerRating';
6 |
7 | const RetailerRatingsContainer = ({ retailerId }) => {
8 | const retailer = useSelector(retailers.byId(retailerId));
9 |
10 | const [isLoaded, setIsLoaded] = useState(false);
11 |
12 | useEffect(() => {
13 | if (retailer) {
14 | setIsLoaded(true);
15 | }
16 | }, [retailer]);
17 |
18 | if (!isLoaded) {
19 | return null;
20 | }
21 |
22 | return (
23 |
24 | {Object.values(retailer.ratings).map((rating) => (
25 |
26 | ))}
27 |
28 | );
29 | };
30 |
31 | export default RetailerRatingsContainer;
32 |
--------------------------------------------------------------------------------
/api/database.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from sqlalchemy import BigInteger, create_engine
4 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
5 |
6 | from api.settings import settings
7 |
8 | engine = create_engine(
9 | str(settings.database_url), echo=settings.sqlalchemy_settings.echo
10 | )
11 | SessionLocal = sessionmaker(
12 | autocommit=settings.sqlalchemy_settings.autocommit,
13 | autoflush=settings.sqlalchemy_settings.autoflush,
14 | bind=engine,
15 | )
16 |
17 |
18 | def get_db():
19 | db = SessionLocal()
20 | try:
21 | yield db
22 | finally:
23 | db.close()
24 |
25 |
26 | class Base(DeclarativeBase):
27 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
28 | created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
29 | updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
30 |
--------------------------------------------------------------------------------
/app/models/retailer_image.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 |
5 |
6 | class RetailerImage(db.Model):
7 | __tablename__ = "retailer_images"
8 |
9 | id = db.Column(db.Integer, primary_key=True)
10 | retailer_id = db.Column(db.Integer, db.ForeignKey("retailers.id"), nullable=False)
11 | image_url = db.Column(db.String, nullable=False)
12 | created_at = db.Column(
13 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
14 | )
15 | updated_at = db.Column(
16 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
17 | )
18 |
19 | retailer = db.relationship("Retailer", back_populates="images")
20 |
21 | def to_dict(self):
22 | return {
23 | "id": self.id,
24 | "retailer_id": self.retailer_id,
25 | "image_url": self.image_url,
26 | "created_at": self.created_at,
27 | }
28 |
--------------------------------------------------------------------------------
/migrations/versions/20210305_151900_update_comments_model.py:
--------------------------------------------------------------------------------
1 | """update comments model
2 |
3 | Revision ID: 32b61a573ad8
4 | Revises: f1ba629b1857
5 | Create Date: 2021-03-05 15:19:00.691588
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '32b61a573ad8'
13 | down_revision = 'f1ba629b1857'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.alter_column('comments', 'comment_id',
21 | existing_type=sa.INTEGER(),
22 | nullable=True)
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.alter_column('comments', 'comment_id',
29 | existing_type=sa.INTEGER(),
30 | nullable=False)
31 | # ### end Alembic commands ###
32 |
--------------------------------------------------------------------------------
/api/migrations/versions/20210305_151900_update_comments_model.py:
--------------------------------------------------------------------------------
1 | """update comments model
2 |
3 | Revision ID: 32b61a573ad8
4 | Revises: f1ba629b1857
5 | Create Date: 2021-03-05 15:19:00.691588
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '32b61a573ad8'
13 | down_revision = 'f1ba629b1857'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.alter_column('comments', 'comment_id',
21 | existing_type=sa.INTEGER(),
22 | nullable=True)
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.alter_column('comments', 'comment_id',
29 | existing_type=sa.INTEGER(),
30 | nullable=False)
31 | # ### end Alembic commands ###
32 |
--------------------------------------------------------------------------------
/api/migrations/versions/20210322_211305_create_lat_and_lng_on_meetups.py:
--------------------------------------------------------------------------------
1 | """create lat and lng on meetups
2 |
3 | Revision ID: 3fc0203ff0f0
4 | Revises: e3919cd21c9b
5 | Create Date: 2021-03-22 21:13:05.056978
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '3fc0203ff0f0'
13 | down_revision = 'e3919cd21c9b'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.add_column('meetups', sa.Column('lat', sa.Numeric(scale=7), nullable=True))
21 | op.add_column('meetups', sa.Column('lng', sa.Numeric(scale=7), nullable=True))
22 | # ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | # ### commands auto generated by Alembic - please adjust! ###
27 | op.drop_column('meetups', 'lng')
28 | op.drop_column('meetups', 'lat')
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/migrations/versions/20210322_211305_create_lat_and_lng_on_meetups.py:
--------------------------------------------------------------------------------
1 | """create lat and lng on meetups
2 |
3 | Revision ID: 3fc0203ff0f0
4 | Revises: e3919cd21c9b
5 | Create Date: 2021-03-22 21:13:05.056978
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '3fc0203ff0f0'
13 | down_revision = 'e3919cd21c9b'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.add_column('meetups', sa.Column('lat', sa.Numeric(scale=7), nullable=True))
21 | op.add_column('meetups', sa.Column('lng', sa.Numeric(scale=7), nullable=True))
22 | # ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | # ### commands auto generated by Alembic - please adjust! ###
27 | op.drop_column('meetups', 'lng')
28 | op.drop_column('meetups', 'lat')
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/app/models/__init__.py:
--------------------------------------------------------------------------------
1 | from app.models.comment import Comment # noqa
2 | from app.models.comment_rating import CommentRating # noqa
3 | from app.models.community import Community # noqa
4 | from app.models.db import db # noqa
5 | from app.models.meetup import Meetup # noqa
6 | from app.models.message import Message # noqa
7 | from app.models.post import Post # noqa
8 | from app.models.post_image import PostsImage # noqa
9 | from app.models.post_rating import PostRating # noqa
10 | from app.models.posts_tag import posts_tags # noqa
11 | from app.models.retailer import Retailer # noqa
12 | from app.models.retailer_image import RetailerImage # noqa
13 | from app.models.retailer_rating import RetailerRating # noqa
14 | from app.models.saved_comment import saved_comments # noqa
15 | from app.models.saved_post import saved_posts # noqa
16 | from app.models.tag import Tag # noqa
17 | from app.models.thread import Thread # noqa
18 | from app.models.user import User # noqa
19 |
--------------------------------------------------------------------------------
/api/migrations/versions/20210312_133251_add_lat_and_lng_columns_to_retailer.py:
--------------------------------------------------------------------------------
1 | """add lat and lng columns to retailer
2 |
3 | Revision ID: e3919cd21c9b
4 | Revises: 32b61a573ad8
5 | Create Date: 2021-03-12 13:32:51.555136
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = 'e3919cd21c9b'
13 | down_revision = '32b61a573ad8'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.add_column('retailers', sa.Column('lat', sa.Numeric(scale=7), nullable=True))
21 | op.add_column('retailers', sa.Column('lng', sa.Numeric(scale=7), nullable=True))
22 | # ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | # ### commands auto generated by Alembic - please adjust! ###
27 | op.drop_column('retailers', 'lng')
28 | op.drop_column('retailers', 'lat')
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/migrations/versions/20210312_133251_add_lat_and_lng_columns_to_retailer.py:
--------------------------------------------------------------------------------
1 | """add lat and lng columns to retailer
2 |
3 | Revision ID: e3919cd21c9b
4 | Revises: 32b61a573ad8
5 | Create Date: 2021-03-12 13:32:51.555136
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = 'e3919cd21c9b'
13 | down_revision = '32b61a573ad8'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.add_column('retailers', sa.Column('lat', sa.Numeric(scale=7), nullable=True))
21 | op.add_column('retailers', sa.Column('lng', sa.Numeric(scale=7), nullable=True))
22 | # ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | # ### commands auto generated by Alembic - please adjust! ###
27 | op.drop_column('retailers', 'lng')
28 | op.drop_column('retailers', 'lat')
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/app/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from app import create_app
4 | from app.models import User, db
5 |
6 |
7 | @pytest.fixture(scope="session")
8 | def app():
9 | app = create_app(testing=True)
10 | yield app
11 |
12 |
13 | @pytest.fixture(scope="session")
14 | def client(app):
15 | with app.test_client() as client:
16 | with app.app_context():
17 | db.drop_all()
18 | db.create_all()
19 | yield client
20 |
21 |
22 | @pytest.fixture(scope="session")
23 | def login_test_user(client):
24 | email = "john@test.com"
25 | username = "johnthetester"
26 | password = "password"
27 | User.create(username=username, email=email, password=password)
28 | login_response = client.post(
29 | "/auth/login",
30 | json={
31 | "credential": email,
32 | "password": password,
33 | },
34 | )
35 | json_login_response = login_response.get_json()
36 | return json_login_response["access_token"]
37 |
--------------------------------------------------------------------------------
/app/models/community.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 |
5 |
6 | class Community(db.Model):
7 | __tablename__ = "communities"
8 |
9 | id = db.Column(db.Integer, primary_key=True)
10 | name = db.Column(db.String(50), nullable=False)
11 | description = db.Column(db.Text, nullable=False)
12 | created_at = db.Column(
13 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
14 | )
15 | updated_at = db.Column(
16 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
17 | )
18 |
19 | posts = db.relationship("Post", back_populates="community")
20 |
21 | def to_simple_dict(self):
22 | return {
23 | "id": self.id,
24 | "name": self.name,
25 | }
26 |
27 | def to_dict(self):
28 | return {
29 | "id": self.id,
30 | "name": self.name,
31 | "description": self.description,
32 | "created_at": self.created_at,
33 | }
34 |
--------------------------------------------------------------------------------
/app/schemas/user.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from pydantic import ConfigDict
4 |
5 | from app.schemas.comment import MinimalCommentResponse
6 | from app.schemas.config import BaseORMModeModel
7 | from app.schemas.meetup import MinimalMeetupResponse
8 | from app.schemas.post import MinimalPostResponse
9 | from app.schemas.retailer import MinimalRetailerResponse
10 |
11 |
12 | class MinimalOrmObjectResponse(BaseORMModeModel):
13 | id: int
14 |
15 |
16 | class MinimalUserResponse(BaseORMModeModel):
17 | id: int
18 | username: str
19 | email: str
20 | created_at: datetime
21 |
22 |
23 | class FullUserResponse(MinimalUserResponse):
24 | meetups: list[MinimalMeetupResponse]
25 | saved_posts: list[MinimalPostResponse]
26 | saved_comments: list[MinimalCommentResponse]
27 | comments: list[MinimalCommentResponse]
28 | posts: list[MinimalPostResponse]
29 | retailers: list[MinimalRetailerResponse]
30 |
31 | model_config = ConfigDict(from_attributes=True)
32 |
--------------------------------------------------------------------------------
/api/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | script_location = migrations
5 | file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
6 | prepend_sys_path = ..
7 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
8 | sqlalchemy.url =
9 |
10 | [post_write_hooks]
11 |
12 | [loggers]
13 | keys = root,sqlalchemy,alembic
14 |
15 | [handlers]
16 | keys = console
17 |
18 | [formatters]
19 | keys = generic
20 |
21 | [logger_root]
22 | level = WARN
23 | handlers = console
24 | qualname =
25 |
26 | [logger_sqlalchemy]
27 | level = WARN
28 | handlers = console
29 | qualname = sqlalchemy.engine
30 |
31 | [logger_alembic]
32 | level = INFO
33 | handlers = console
34 | qualname = alembic
35 |
36 | [handler_console]
37 | class = StreamHandler
38 | args = (sys.stderr,)
39 | level = NOTSET
40 | formatter = generic
41 |
42 | [formatter_generic]
43 | format = %(levelname)-5.5s [%(name)s] %(message)s
44 | datefmt = %H:%M:%S
45 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/InputField/InputField.js:
--------------------------------------------------------------------------------
1 | const InputField = ({ name, type, placeholder, value, onChange }) => {
2 | if (type === 'textarea') {
3 | return (
4 |
5 |
13 |
14 | );
15 | }
16 |
17 | return (
18 |
19 |
27 |
28 | );
29 | };
30 |
31 | export default InputField;
32 |
--------------------------------------------------------------------------------
/migrations/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # template used to generate migration files
5 | file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
6 |
7 | # set to 'true' to run the environment during
8 | # the 'revision' command, regardless of autogenerate
9 | # revision_environment = false
10 |
11 |
12 | # Logging configuration
13 | [loggers]
14 | keys = root,sqlalchemy,alembic
15 |
16 | [handlers]
17 | keys = console
18 |
19 | [formatters]
20 | keys = generic
21 |
22 | [logger_root]
23 | level = WARN
24 | handlers = console
25 | qualname =
26 |
27 | [logger_sqlalchemy]
28 | level = WARN
29 | handlers =
30 | qualname = sqlalchemy.engine
31 |
32 | [logger_alembic]
33 | level = INFO
34 | handlers =
35 | qualname = alembic
36 |
37 | [handler_console]
38 | class = StreamHandler
39 | args = (sys.stderr,)
40 | level = NOTSET
41 | formatter = generic
42 |
43 | [formatter_generic]
44 | format = %(levelname)-5.5s [%(name)s] %(message)s
45 | datefmt = %H:%M:%S
46 |
--------------------------------------------------------------------------------
/react-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
15 |
20 | Qwerkey
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/react-app/src/context/index.js:
--------------------------------------------------------------------------------
1 | import ModalProvider from './ModalContext';
2 | import AuthProvider from './AuthContext';
3 | import CreatePostProvider from './CreatePostContext';
4 | import CommentProvider from './CommentContext';
5 | import DarkModeProvider from './DarkModeContext';
6 | import RetailerRatingProvider from './RetailerRatingContext';
7 | import CollapsedSidebarProvider from './CollapsedSidebarContext';
8 | import SearchProvider from './SearchContext';
9 |
10 | const ContextProvider = ({ children }) => (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 |
28 | export default ContextProvider;
29 |
--------------------------------------------------------------------------------
/app/seeds/post_images.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "post_id": 1,
4 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/Z-jvNayP8GFj5m6vW0lUrCRgwPWYw1mtk8XBVU2m9eI.jpg"
5 | },
6 | {
7 | "post_id": 2,
8 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/vfk7yqpiq2n61.png"
9 | },
10 | {
11 | "post_id": 3,
12 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/nab5nlodl2n61.jpg"
13 | },
14 | {
15 | "post_id": 4,
16 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/hqgyybdttum61.png"
17 | },
18 | {
19 | "post_id": 4,
20 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/9j663ey1k3n61.jpg"
21 | },
22 | {
23 | "post_id": 5,
24 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/5le61y26o1n61.jpg"
25 | },
26 | {
27 | "post_id": 6,
28 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/51sz52qakzm61.jpg"
29 | },
30 | {
31 | "post_id": 7,
32 | "image_url": "https://qwerkey-local.s3.us-east-2.amazonaws.com/3dzr3ie981n61.jpg"
33 | }
34 | ]
35 |
--------------------------------------------------------------------------------
/app/tests/models/test_user.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from app.models import User
4 |
5 |
6 | class TestUserModel:
7 | def test__user_password_property__returns_str(self, test_user):
8 | assert type(test_user.password) == str
9 | assert test_user.password != "password"
10 |
11 | def test__user_password_setter__sets_password_property(self, test_user):
12 | old_hashed_password = test_user.password
13 | test_user.password = "new password"
14 |
15 | assert test_user.password != old_hashed_password
16 |
17 | @pytest.mark.parametrize(
18 | "input_password,expected_bool",
19 | [
20 | ("password", True),
21 | ("incorrect", False),
22 | ("", False),
23 | ],
24 | )
25 | def test__user_check_password_method__returns_bool(
26 | self, input_password, expected_bool, test_user
27 | ):
28 | assert test_user.check_password(input_password) == expected_bool
29 |
30 |
31 | @pytest.fixture
32 | def test_user():
33 | return User(username="testuser", email="test@user.io", password="password")
34 |
--------------------------------------------------------------------------------
/app/forms/login.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from sqlalchemy import or_
3 | from wtforms import StringField
4 | from wtforms.validators import DataRequired, ValidationError
5 |
6 | from app.models import User
7 |
8 |
9 | def user_exists(form, field):
10 | user = User.query.filter(
11 | or_(User.email == field.data, User.username == field.data)
12 | ).first()
13 | if not user:
14 | raise ValidationError("Invalid credentials.")
15 |
16 |
17 | def password_matches(form, field):
18 | password = field.data
19 | credential = form.data["credential"]
20 | user = User.query.filter(
21 | or_(User.email == credential, User.username == credential)
22 | ).first()
23 | if not user:
24 | raise ValidationError("Invalid credentials.")
25 | if not user.check_password(password):
26 | raise ValidationError("Invalid credentials.")
27 |
28 |
29 | class LoginForm(FlaskForm):
30 | credential = StringField("credential", validators=[DataRequired(), user_exists])
31 | password = StringField("password", validators=[DataRequired(), password_matches])
32 |
--------------------------------------------------------------------------------
/app/forms/signup.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import PasswordField, StringField
3 | from wtforms.validators import DataRequired, Email, EqualTo, ValidationError
4 |
5 | from app.models import User
6 |
7 |
8 | def users_username_exists(form, field):
9 | user = User.query.filter(User.username == field.data).first()
10 | if user:
11 | raise ValidationError("User is already registered.")
12 |
13 |
14 | def users_email_exists(form, field):
15 | email = field.data
16 | user = User.query.filter(User.email == email).first()
17 | if user:
18 | raise ValidationError("User is already registered.")
19 |
20 |
21 | class SignUpForm(FlaskForm):
22 | username = StringField(
23 | "username", validators=[DataRequired(), users_username_exists]
24 | )
25 | email = StringField(
26 | "email", validators=[DataRequired(), Email(), users_email_exists]
27 | )
28 | password = PasswordField(
29 | "password",
30 | validators=[DataRequired(), EqualTo("confirm", message="Passwords must match")],
31 | )
32 | confirm = PasswordField()
33 |
--------------------------------------------------------------------------------
/migrations/versions/20201120_150602_create_users_table.py:
--------------------------------------------------------------------------------
1 | """create_users_table
2 |
3 | Revision ID: ffdc0a98111c
4 | Revises:
5 | Create Date: 2020-11-20 15:06:02.230689
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = 'ffdc0a98111c'
13 | down_revision = None
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.create_table('users',
21 | sa.Column('id', sa.Integer(), nullable=False),
22 | sa.Column('username', sa.String(length=40), nullable=False),
23 | sa.Column('email', sa.String(length=255), nullable=False),
24 | sa.Column('hashed_password', sa.String(length=255), nullable=False),
25 | sa.PrimaryKeyConstraint('id'),
26 | sa.UniqueConstraint('email'),
27 | sa.UniqueConstraint('username')
28 | )
29 | # ### end Alembic commands ###qqqqqqqqq
30 |
31 |
32 | def downgrade():
33 | # ### commands auto generated by Alembic - please adjust! ###
34 | op.drop_table('users')
35 | # ### end Alembic commands ###
36 |
--------------------------------------------------------------------------------
/api/migrations/versions/20201120_150602_create_users_table.py:
--------------------------------------------------------------------------------
1 | """create_users_table
2 |
3 | Revision ID: ffdc0a98111c
4 | Revises:
5 | Create Date: 2020-11-20 15:06:02.230689
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = 'ffdc0a98111c'
13 | down_revision = None
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.create_table('users',
21 | sa.Column('id', sa.Integer(), nullable=False),
22 | sa.Column('username', sa.String(length=40), nullable=False),
23 | sa.Column('email', sa.String(length=255), nullable=False),
24 | sa.Column('hashed_password', sa.String(length=255), nullable=False),
25 | sa.PrimaryKeyConstraint('id'),
26 | sa.UniqueConstraint('email'),
27 | sa.UniqueConstraint('username')
28 | )
29 | # ### end Alembic commands ###qqqqqqqqq
30 |
31 |
32 | def downgrade():
33 | # ### commands auto generated by Alembic - please adjust! ###
34 | op.drop_table('users')
35 | # ### end Alembic commands ###
36 |
--------------------------------------------------------------------------------
/app/routes/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | from app.routes.api.auth import auth
4 | from app.routes.api.comment import comment
5 | from app.routes.api.community import community
6 | from app.routes.api.lat_long import lat_long
7 | from app.routes.api.meetup import meetup
8 | from app.routes.api.post import post
9 | from app.routes.api.posts_image import posts_image
10 | from app.routes.api.retailer import retailer
11 | from app.routes.api.search import search
12 | from app.routes.api.user import user
13 |
14 | api = Blueprint("api", __name__)
15 |
16 | api.register_blueprint(auth, url_prefix="/auth")
17 | api.register_blueprint(user, url_prefix="/users")
18 | api.register_blueprint(post, url_prefix="/posts")
19 | api.register_blueprint(posts_image, url_prefix="/post_images")
20 | api.register_blueprint(comment, url_prefix="/comments")
21 | api.register_blueprint(retailer, url_prefix="/retailers")
22 | api.register_blueprint(search, url_prefix="/search")
23 | api.register_blueprint(community, url_prefix="/communities")
24 | api.register_blueprint(lat_long, url_prefix="/lat_long")
25 | api.register_blueprint(meetup, url_prefix="/meetups")
26 |
--------------------------------------------------------------------------------
/react-app/src/components/About/About.js:
--------------------------------------------------------------------------------
1 | import { GitHub, Code, LinkedIn, FolderShared } from '@mui/icons-material';
2 |
3 | import logo from '../../images/logo.png';
4 |
5 | const About = () => {
6 | return (
7 |
8 |
9 |
Created by Peter Mai © 2021
10 |
11 |
31 |
32 |

33 |
34 |
35 | );
36 | };
37 |
38 | export default About;
39 |
--------------------------------------------------------------------------------
/react-app/src/store/sidebar.js:
--------------------------------------------------------------------------------
1 | import { SET_SIDEBAR_COMMUNITY, SET_SIDEBAR_COMMUNITIES } from './constants';
2 | import { setSidebarCommunity, setSidebarCommunities } from './actions';
3 | import api from './api';
4 |
5 | export const getSidebarPopularCommunities = () => async (dispatch) => {
6 | const res = await api(`/api/communities/popular`);
7 | const communities = await res.json();
8 | dispatch(setSidebarCommunities(communities));
9 | return communities;
10 | };
11 |
12 | export const getSidebarCommunity = (name) => async (dispatch) => {
13 | const res = await api(`/api/communities/${name}`);
14 | const community = await res.json();
15 | if (!community.errors) {
16 | dispatch(setSidebarCommunity(community));
17 | }
18 | return community;
19 | };
20 |
21 | const initialState = {
22 | popular: [],
23 | community: null,
24 | };
25 |
26 | const sidebarReducer = (state = initialState, action) => {
27 | switch (action.type) {
28 | case SET_SIDEBAR_COMMUNITIES:
29 | return { ...state, popular: action.communities.map(({ id }) => id) };
30 | case SET_SIDEBAR_COMMUNITY:
31 | return { ...state, community: action.community.id };
32 | default:
33 | return state;
34 | }
35 | };
36 |
37 | export default sidebarReducer;
38 |
--------------------------------------------------------------------------------
/app/models/comment_rating.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy.orm import Mapped
4 |
5 | from app import models
6 | from app.models.db import db
7 | from app.schemas.user import MinimalUserResponse
8 |
9 |
10 | class CommentRating(db.Model):
11 | __tablename__ = "comment_ratings"
12 |
13 | id = db.Column(db.Integer, primary_key=True)
14 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
15 | comment_id = db.Column(db.Integer, db.ForeignKey("comments.id"), nullable=False)
16 | rating = db.Column(db.Integer, nullable=False)
17 | created_at = db.Column(
18 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
19 | )
20 | updated_at = db.Column(
21 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
22 | )
23 |
24 | user: Mapped["models.user.User"] = db.relationship(back_populates="rated_comments")
25 | comment: Mapped["models.comment.Comment"] = db.relationship(
26 | back_populates="ratings"
27 | )
28 |
29 | def to_dict(self):
30 | return {
31 | "id": self.id,
32 | "user": MinimalUserResponse.from_orm(self.user).dict(),
33 | "comment_id": self.comment_id,
34 | "rating": self.rating,
35 | }
36 |
--------------------------------------------------------------------------------
/app/models/message.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.schemas.user import MinimalUserResponse
5 |
6 |
7 | class Message(db.Model):
8 | __tablename__ = "messages"
9 |
10 | id = db.Column(db.Integer, primary_key=True)
11 | sender_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
12 | recipient_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
13 | body = db.Column(db.String(255), nullable=False)
14 | created_at = db.Column(
15 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
16 | )
17 | updated_at = db.Column(
18 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
19 | )
20 |
21 | sender = db.relationship(
22 | "User", foreign_keys=[sender_id], back_populates="sent_messages"
23 | )
24 | recipient = db.relationship(
25 | "User", foreign_keys=[recipient_id], back_populates="received_messages"
26 | )
27 |
28 | def to_dict(self):
29 | return {
30 | "id": self.id,
31 | "sender": MinimalUserResponse.from_orm(self.sender).dict(),
32 | "recipient": MinimalUserResponse.from_orm(self.recipient).dict(),
33 | "created_at": self.created_at,
34 | }
35 |
--------------------------------------------------------------------------------
/app/models/post_rating.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.schemas.user import MinimalUserResponse
5 |
6 |
7 | class PostRating(db.Model):
8 | __tablename__ = "post_ratings"
9 |
10 | id = db.Column(db.Integer, primary_key=True)
11 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
12 | post_id = db.Column(db.Integer, db.ForeignKey("posts.id"), nullable=False)
13 | rating = db.Column(db.Integer, nullable=False)
14 | created_at = db.Column(
15 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
16 | )
17 | updated_at = db.Column(
18 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
19 | )
20 |
21 | user = db.relationship("User", back_populates="rated_posts")
22 | post = db.relationship("Post", back_populates="ratings")
23 |
24 | def to_simple_dict(self):
25 | return {
26 | "user_id": self.user_id,
27 | "rating": self.rating,
28 | }
29 |
30 | def to_dict(self):
31 | return {
32 | "id": self.id,
33 | "user": MinimalUserResponse.from_orm(self.user).dict(),
34 | "post": self.post.to_simple_dict(),
35 | "rating": self.rating,
36 | }
37 |
--------------------------------------------------------------------------------
/react-app/src/context/ModalContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useRef, useState, useEffect, useContext } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import { useDarkModeContext } from './DarkModeContext';
5 |
6 | const ModalContext = createContext();
7 |
8 | const ModalProvider = ({ children }) => {
9 | const modalRef = useRef();
10 | const [value, setValue] = useState();
11 |
12 | useEffect(() => {
13 | setValue(modalRef.current);
14 | }, []);
15 |
16 | return (
17 | <>
18 | {children}
19 |
20 | >
21 | );
22 | };
23 |
24 | export const Modal = ({ onClose, children }) => {
25 | const { isDarkMode } = useDarkModeContext();
26 | const modalNode = useContext(ModalContext);
27 | if (!modalNode) return null;
28 |
29 | return ReactDOM.createPortal(
30 |
35 |
36 |
{children}
37 |
,
38 | modalNode
39 | );
40 | };
41 |
42 | export default ModalProvider;
43 |
--------------------------------------------------------------------------------
/app/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | class Config:
5 | TESTING = False
6 | SECRET_KEY = os.environ.get("SECRET_KEY")
7 |
8 | SQLALCHEMY_TRACK_MODIFICATIONS = False
9 | SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
10 | SQLALCHEMY_ECHO = False
11 |
12 | S3_BUCKET = os.environ.get("S3_BUCKET_NAME")
13 | S3_KEY = os.environ.get("S3_ACCESS_KEY")
14 | S3_SECRET = os.environ.get("S3_SECRET_ACCESS_KEY")
15 | S3_LOCATION = f"http://{S3_BUCKET}.s3.amazonaws.com/"
16 |
17 | OPEN_CAGE_API_KEY = os.environ.get("OPEN_CAGE_API_KEY")
18 |
19 | def __new__(cls, testing):
20 | is_production = os.environ.get("FLASK_ENV", "development") == "production"
21 |
22 | if testing:
23 | return TestingConfig
24 | elif is_production:
25 | return ProductionConfig
26 | else:
27 | return DevelopmentConfig
28 |
29 | def __init__(self, *args, **kwargs):
30 | super().__init__(*args, **kwargs)
31 |
32 |
33 | class ProductionConfig(Config):
34 | pass
35 |
36 |
37 | class DevelopmentConfig(Config):
38 | SQLALCHEMY_ECHO = True
39 |
40 |
41 | class TestingConfig(Config):
42 | TESTING = True
43 | SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
44 | WTF_CSRF_ENABLED = False
45 |
--------------------------------------------------------------------------------
/react-app/src/components/DarkModeToggle/DarkModeToggle.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { Brightness2, Brightness7 } from '@mui/icons-material';
4 |
5 | import { useDarkModeContext } from '../../context/DarkModeContext';
6 |
7 | const DarkModeToggle = () => {
8 | const { isDarkMode, setIsDarkMode } = useDarkModeContext();
9 |
10 | useEffect(() => {
11 | localStorage.setItem('isDarkMode', isDarkMode);
12 | }, [isDarkMode]);
13 |
14 | const toggleDarkMode = () => {
15 | setIsDarkMode((prev) => !prev);
16 | };
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
35 |
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default DarkModeToggle;
43 |
--------------------------------------------------------------------------------
/app/routes/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from flask import Blueprint, redirect, request
4 | from flask_wtf.csrf import generate_csrf
5 |
6 | from app.routes.api import api
7 |
8 | routes = Blueprint("routes", __name__, static_folder="../static")
9 |
10 | routes.register_blueprint(api, url_prefix="/api")
11 | is_production = os.environ.get("FLASK_ENV", "development") == "production"
12 |
13 |
14 | @routes.before_request
15 | def https_redirect():
16 | if is_production:
17 | if request.headers.get("X-Forwarded-Proto") == "http":
18 | url = request.url.replace("http://", "https://", 1)
19 | code = 301
20 | return redirect(url, code=code)
21 |
22 |
23 | @routes.after_request
24 | def inject_csrf_token(response):
25 | response.set_cookie(
26 | "csrf_token",
27 | generate_csrf(),
28 | secure=True if is_production else False,
29 | samesite="Strict" if is_production else None,
30 | )
31 | return response
32 |
33 |
34 | @routes.route("/favicon.ico")
35 | def favicon():
36 | return routes.send_static_file("favicon.ico")
37 |
38 |
39 | @routes.route("/", defaults={"path": ""})
40 | @routes.route("/")
41 | def react_root(path):
42 | print("path", path)
43 | return routes.send_static_file("index.html")
44 |
--------------------------------------------------------------------------------
/react-app/src/components/MeetupPage/MeetupPage.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { useParams } from 'react-router-dom';
4 |
5 | import { meetups } from '../../store/selectors';
6 | import { getMeetupById, getMeetupLocation } from '../../store/meetups';
7 | import Meetup from '../Meetup';
8 | import Map from '../Map';
9 |
10 | const MeetupPage = () => {
11 | const { meetupId } = useParams();
12 |
13 | const dispatch = useDispatch();
14 | const meetup = useSelector(meetups.byId(meetupId));
15 |
16 | const [isLoaded, setIsLoaded] = useState(false);
17 |
18 | useEffect(() => {
19 | dispatch(getMeetupById(meetupId));
20 | }, [dispatch, meetupId]);
21 |
22 | useEffect(() => {
23 | (async () => {
24 | if (meetup) {
25 | if (!(meetup.lng || meetup.lat)) {
26 | await dispatch(getMeetupLocation(meetupId));
27 | }
28 | setIsLoaded(true);
29 | }
30 | })();
31 | }, [dispatch, meetup, meetupId]);
32 |
33 | if (!isLoaded) {
34 | return null;
35 | }
36 |
37 | return (
38 | <>
39 |
40 | {meetup.lng && meetup.lat && }
41 | >
42 | );
43 | };
44 |
45 | export default MeetupPage;
46 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/UserMenu/UserMenu.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { NavLink } from 'react-router-dom';
4 |
5 | import { session } from '../../../store/selectors';
6 | import DarkModeToggle from '../../DarkModeToggle';
7 | import LogoutButton from '../../LogoutButton';
8 | import NavButton from '../NavButton';
9 |
10 | const UserMenu = ({ createPostBtnHandler, userMenuRef }) => {
11 | const user = useSelector(session.user());
12 | const [isLoaded, setIsLoaded] = useState(false);
13 |
14 | useEffect(() => {
15 | if (user) {
16 | setIsLoaded(true);
17 | }
18 | }, [user]);
19 |
20 | if (!isLoaded || !user) {
21 | return null;
22 | }
23 |
24 | return (
25 |
29 |
30 |
31 | {user.username}
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default UserMenu;
42 |
--------------------------------------------------------------------------------
/react-app/src/store/search.js:
--------------------------------------------------------------------------------
1 | import { SET_SEARCH } from './constants';
2 | import { setSearch } from './actions';
3 | import api from './api';
4 |
5 | export const getQuery =
6 | (queryString, type, field, state, city) => async (dispatch) => {
7 | const res = await api(
8 | `/api/search?query=${queryString}${type ? `&type=${type}` : ''}${
9 | field ? `&field=${field}` : ''
10 | }${city ? `&city=${city}` : ''}${state ? `&state=${state}` : ''}`
11 | );
12 | const data = await res.json();
13 | const searchData = {};
14 |
15 | searchData.posts = data.posts || [];
16 | searchData.comments = data.comments || [];
17 | searchData.retailers = data.retailers || [];
18 |
19 | dispatch(setSearch(searchData));
20 | return data;
21 | };
22 |
23 | const initialState = {
24 | posts: [],
25 | comments: [],
26 | retailers: [],
27 | };
28 |
29 | const searchReducer = (state = initialState, action) => {
30 | switch (action.type) {
31 | case SET_SEARCH:
32 | return {
33 | ...state,
34 | posts: action.posts.map((post) => post.id),
35 | comments: action.comments.map((comment) => comment.id),
36 | retailers: action.retailers.map((retailer) => retailer.id),
37 | };
38 | default:
39 | return state;
40 | }
41 | };
42 |
43 | export default searchReducer;
44 |
--------------------------------------------------------------------------------
/react-app/src/components/Retailer/Retailer.js:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 |
3 | import UserName from '../parts/UserName';
4 | import options from '../../utils/localeDateString';
5 |
6 | const Retailer = ({ retailer }) => {
7 | return (
8 |
9 |
10 |
14 | 's{' '}
15 |
16 | {retailer.name}
17 |
18 |
19 |
20 | Average rating of:{' '}
21 | {Object.values(retailer.ratings).reduce(
22 | (acc, { rating }, idx) => (acc + rating) / (idx + 1),
23 | 0
24 | )}
25 |
26 |
{retailer.description}
27 |
28 |
29 | Est. {new Date(retailer.created_at).toLocaleString(...options())}
30 |
31 |
32 | Located in {retailer.city}, {retailer.state}
33 |
34 |
35 | );
36 | };
37 |
38 | export default Retailer;
39 |
--------------------------------------------------------------------------------
/app/models/retailer_rating.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.schemas.user import MinimalUserResponse
5 |
6 |
7 | class RetailerRating(db.Model):
8 | __tablename__ = "retailer_ratings"
9 |
10 | id = db.Column(db.Integer, primary_key=True)
11 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
12 | retailer_id = db.Column(db.Integer, db.ForeignKey("retailers.id"), nullable=False)
13 | rating = db.Column(db.Integer, nullable=False)
14 | created_at = db.Column(
15 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
16 | )
17 | updated_at = db.Column(
18 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
19 | )
20 |
21 | user = db.relationship("User")
22 | retailer = db.relationship("Retailer", back_populates="ratings")
23 |
24 | def to_dict(self):
25 | return {
26 | "id": self.id,
27 | "user": MinimalUserResponse.from_orm(self.user).dict(),
28 | "retailer_id": self.retailer_id,
29 | "rating": self.rating,
30 | "created_at": self.created_at,
31 | }
32 |
33 | def to_simple_dict(self):
34 | return {
35 | "id": self.id,
36 | "user": MinimalUserResponse.from_orm(self.user).dict(),
37 | "rating": self.rating,
38 | }
39 |
--------------------------------------------------------------------------------
/react-app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const colors = require('tailwindcss/colors');
2 | const defaultTheme = require('tailwindcss/defaultTheme');
3 |
4 | module.exports = {
5 | purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'],
6 | darkMode: 'class', // or 'media' or 'class'
7 | theme: {
8 | boxShadow: {
9 | ...defaultTheme.boxShadow,
10 | light:
11 | '0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)',
12 | 'light-lg':
13 | '0 10px 15px 3px rgba(255, 255, 255, 0.1), 0 4px 6px -2px rgba(255, 255, 255, 0.06)',
14 | },
15 | colors: {
16 | ...colors,
17 | green: {
18 | DEFAULT: '#00ce86',
19 | dark: '#007a50',
20 | },
21 | purple: {
22 | DEFAULT: '#9556dc',
23 | dark: '#391463',
24 | },
25 | },
26 | fontFamily: {
27 | big: ['Montserrat', ...defaultTheme.fontFamily.sans],
28 | sans: ['"Carrois Gothic"', ...defaultTheme.fontFamily.sans],
29 | },
30 | extend: {
31 | gridTemplateRows: {
32 | layout: 'auto minmax(98vh, 1fr) auto',
33 | },
34 | },
35 | },
36 | variants: {
37 | extend: {
38 | boxShadow: ['dark'],
39 | opacity: ['disabled'],
40 | cursor: ['disabled'],
41 | borderColor: ['disabled'],
42 | },
43 | },
44 | plugins: [],
45 | corePlugins: {
46 | ringColor: false,
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/react-app/src/components/PostPage/PostPage.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { useParams } from 'react-router-dom';
4 |
5 | import { session, posts } from '../../store/selectors';
6 | import { getPostById } from '../../store/posts';
7 | import { getCommentsByPost } from '../../store/comments';
8 | import Post from '../Post';
9 | import CommentThreadContainer from '../CommentThreadContainer';
10 | import CreateCommentForm from '../CreateCommentForm';
11 |
12 | const PostPage = () => {
13 | const { postId } = useParams();
14 | const dispatch = useDispatch();
15 |
16 | const user = useSelector(session.user());
17 | const post = useSelector(posts.byId(postId));
18 |
19 | const [isLoaded, setIsLoaded] = useState(false);
20 |
21 | useEffect(() => {
22 | window.scrollTo(0, 0);
23 | dispatch(getPostById(postId));
24 | dispatch(getCommentsByPost(postId));
25 | }, [dispatch, postId]);
26 |
27 | useEffect(() => {
28 | if (post) {
29 | setIsLoaded(true);
30 | }
31 | }, [post]);
32 |
33 | if (!isLoaded) {
34 | return null;
35 | }
36 |
37 | return (
38 | <>
39 |
40 | {user && (
41 |
42 |
43 |
44 | )}
45 |
46 | >
47 | );
48 | };
49 |
50 | export default PostPage;
51 |
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | url = "https://pypi.org/simple"
3 | verify_ssl = true
4 | name = "pypi"
5 |
6 | [packages]
7 | click = "*"
8 | gunicorn = "==20.0.4"
9 | itsdangerous = "*"
10 | python-dotenv = "==1.0"
11 | six = "==1.15.0"
12 | Flask-Cors = "==3.0.8"
13 | WTForms = "==2.3.3"
14 | Flask-JWT-Extended = "==3.24.1"
15 | email-validator = "*"
16 | alembic = "*"
17 | python-dateutil = "==2.8.1"
18 | python-editor = "==1.0.4"
19 | Mako = "==1.1.3"
20 | PyJWT = "==1.7.1"
21 | boto3 = "==1.17.26"
22 | pytest-dotenv = "==0.5.2"
23 | requests = "==2.25.1"
24 | SQLAlchemy-Utils = "==0.36.8"
25 | attrs = "==20.3.0"
26 | botocore = "==1.20.26"
27 | certifi = "==2020.12.5"
28 | chardet = "==4.0.0"
29 | dnspython = "==2.1.0"
30 | idna = "==2.10"
31 | iniconfig = "==1.1.1"
32 | jmespath = "==0.10.0"
33 | pluggy = "==0.13.1"
34 | pyparsing = "==2.4.7"
35 | s3transfer = "==0.3.4"
36 | toml = "==0.10.2"
37 | urllib3 = "==1.26.3"
38 | pydantic = "==v2.0"
39 | sqlalchemy = "*"
40 | flask-sqlalchemy = "*"
41 | flask-migrate = "==4.0.4"
42 | flask = "*"
43 | werkzeug = "*"
44 | jinja2 = "==3.1.2"
45 | markupsafe = "==2.1.3"
46 | flask-login = "==0.6.2"
47 | flask-wtf = "==1.1.1"
48 | pydantic-settings = "==2.0"
49 | fastapi = {version = "==0.100.0-beta2", extras = ["all"]}
50 | uvicorn = "*"
51 | httpx = "*"
52 |
53 | [dev-packages]
54 | psycopg2-binary = "==2.9.6"
55 | autopep8 = "*"
56 | pylint = "*"
57 | httpx = "*"
58 | pytest = "*"
59 |
60 | [requires]
61 | python_version = "3.11"
62 |
--------------------------------------------------------------------------------
/app/helpers.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | import boto3
4 | import botocore # noqa
5 |
6 | from app.config import Config
7 |
8 | ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "svg"}
9 |
10 | s3 = boto3.client(
11 | "s3", aws_access_key_id=Config.S3_KEY, aws_secret_access_key=Config.S3_SECRET
12 | )
13 |
14 |
15 | def upload_file_to_s3(file, bucket_name, acl="public-read"):
16 | try:
17 | s3.upload_fileobj(
18 | file,
19 | bucket_name,
20 | file.filename,
21 | ExtraArgs={"ACL": acl, "ContentType": file.content_type},
22 | )
23 |
24 | except Exception as e:
25 | print("Something Happened: ", e)
26 | return {"errors": str(e)}
27 |
28 | return f"{Config.S3_LOCATION}{file.filename}"
29 |
30 |
31 | def allowed_file(filename):
32 | return "." in filename and filename.split(".", 1)[1].lower() in ALLOWED_EXTENSIONS
33 |
34 |
35 | def get_unique_filename(filename):
36 | ext = filename.rsplit(".", 1)[1].lower()
37 | unique_filename = uuid.uuid4().hex
38 | return f"{unique_filename}.{ext}"
39 |
40 |
41 | def validation_errors_to_error_messages(validation_errors):
42 | """
43 | Simple function that turns the WTForms validation errors into a simple list
44 | """
45 | errorMessages = []
46 | for field in validation_errors:
47 | for error in validation_errors[field]:
48 | errorMessages.append(f"{field} : {error}")
49 | return errorMessages
50 |
--------------------------------------------------------------------------------
/react-app/src/components/SearchBar/SearchBar.js:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import { getQuery } from '../../store/search';
5 | import { useSearchContext } from '../../context/SearchContext';
6 |
7 | const SearchBar = () => {
8 | const dispatch = useDispatch();
9 | const navigate = useNavigate();
10 | const { searchInput, setSearchInput, setSearched } = useSearchContext();
11 |
12 | const updateSearchInput = (e) => {
13 | setSearchInput(e.target.value);
14 | };
15 |
16 | const submitHandler = (e) => {
17 | e.preventDefault();
18 | dispatch(getQuery(searchInput));
19 | setSearched(true);
20 | navigate(`/search?query=${searchInput}`);
21 | };
22 |
23 | return (
24 |
25 |
37 |
38 | );
39 | };
40 |
41 | export default SearchBar;
42 |
--------------------------------------------------------------------------------
/app/integration_tests/routes/test_auth.py:
--------------------------------------------------------------------------------
1 | from app.models import User
2 |
3 |
4 | class TestAuthFunctionality:
5 | def test_valid_login(self, client):
6 | User.create(
7 | username="johnthetester", email="john@test.com", password="password"
8 | )
9 |
10 | response = client.get("/api/auth")
11 |
12 | response = client.post(
13 | "/api/auth/login",
14 | json={"credential": "johnthetester", "password": "password"},
15 | )
16 | json_response = response.get_json()
17 | user = User.query.filter_by(email="john@test.com").first()
18 |
19 | assert json_response["username"] == user.username
20 | assert json_response["email"] == user.email
21 | assert json_response["id"] == user.id
22 | assert response.status_code == 200
23 |
24 | def text_invalid_login(self, client):
25 | User.create(
26 | username="johnthetester", email="john@test.com", password="password"
27 | )
28 |
29 | response = client.get("/api/auth")
30 | csrf_token = response.headers["Set-Cookie"]
31 |
32 | response = client.post(
33 | "/api/auth/login",
34 | json={"credential": "johnthetester", "password": "bad password"},
35 | headers={"csrf_token": csrf_token},
36 | )
37 | json_response = response.get_json()
38 | errors = json_response["errors"]
39 | assert errors == ["Invalid credentials"]
40 | assert response.status_code == 200
41 |
--------------------------------------------------------------------------------
/app/models/meetup.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.schemas.user import MinimalUserResponse
5 |
6 |
7 | class Meetup(db.Model):
8 | __tablename__ = "meetups"
9 |
10 | id = db.Column(db.Integer, primary_key=True)
11 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
12 | name = db.Column(db.String(50), nullable=False)
13 | description = db.Column(db.Text, nullable=False)
14 | city = db.Column(db.String(50), nullable=False)
15 | state = db.Column(db.String(25), nullable=False)
16 | lat = db.Column(db.Numeric(scale=7))
17 | lng = db.Column(db.Numeric(scale=7))
18 | date = db.Column(db.DateTime, nullable=False)
19 | created_at = db.Column(
20 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
21 | )
22 | updated_at = db.Column(
23 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
24 | )
25 |
26 | user = db.relationship("User", back_populates="meetups")
27 |
28 | def to_dict(self):
29 | return {
30 | "id": self.id,
31 | "user": MinimalUserResponse.from_orm(self.user).dict(),
32 | "name": self.name,
33 | "description": self.description,
34 | "city": self.city,
35 | "state": self.state,
36 | "lat": float(self.lat or 0),
37 | "lng": float(self.lng or 0),
38 | "date": self.date,
39 | "created_at": self.created_at,
40 | "updated_at": self.updated_at,
41 | }
42 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerRatingForm/RetailerRatingForm.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 |
4 | import { session } from '../../store/selectors';
5 | import { createRetailerRating } from '../../store/retailers';
6 | import FormTitle from '../parts/FormTitle';
7 | import InputField from '../parts/InputField';
8 | import FormErrors from '../parts/FormErrors';
9 | import SubmitFormButton from '../parts/SubmitFormButton';
10 |
11 | const RetailerRatingForm = ({ retailerId }) => {
12 | const dispatch = useDispatch();
13 | const user = useSelector(session.user());
14 |
15 | const [rating, setRating] = useState(1);
16 | const [errors, setErrors] = useState([]);
17 |
18 | const updateRating = (e) => {
19 | setRating(e.target.value);
20 | };
21 |
22 | const submitHandler = async (e) => {
23 | e.preventDefault();
24 | const retailer = await dispatch(
25 | createRetailerRating({ rating, user_id: user.id }, retailerId)
26 | );
27 | if (retailer.errors) {
28 | setErrors(retailer.errors);
29 | } else {
30 | setRating(1);
31 | }
32 | };
33 |
34 | return (
35 | <>
36 |
48 | >
49 | );
50 | };
51 |
52 | export default RetailerRatingForm;
53 |
--------------------------------------------------------------------------------
/react-app/src/components/CreateCommentForm/CreateCommentForm.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useDispatch } from 'react-redux';
3 |
4 | import { createComment } from '../../store/comments';
5 | import InputField from '../parts/InputField';
6 | import SubmitFormButton from '../parts/SubmitFormButton';
7 |
8 | const CreateCommentForm = ({ userId, postId }) => {
9 | const dispatch = useDispatch();
10 | const [body, setBody] = useState('');
11 | const [errors, setErrors] = useState([]);
12 |
13 | const updateBody = (e) => {
14 | setBody(e.target.value);
15 | };
16 |
17 | const submitComment = async (e) => {
18 | e.preventDefault();
19 | const comment = {
20 | body,
21 | user_id: userId,
22 | };
23 | const post = await dispatch(createComment(comment, postId));
24 | if (post.errors) {
25 | setErrors(['A body is required to make a comment.']);
26 | } else {
27 | setBody('');
28 | }
29 | };
30 |
31 | return (
32 |
51 | );
52 | };
53 |
54 | export default CreateCommentForm;
55 |
--------------------------------------------------------------------------------
/api/settings.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from pydantic import PostgresDsn, computed_field
4 | from pydantic_settings import BaseSettings, SettingsConfigDict
5 |
6 |
7 | class EnvEnum(str, Enum):
8 | prod = "PROD"
9 | staging = "STAGING"
10 | dev = "DEV"
11 |
12 |
13 | class SqlalchemySettings(BaseSettings):
14 | model_config = SettingsConfigDict(
15 | env_prefix="sqla_",
16 | env_file=".env",
17 | env_file_encoding="utf-8",
18 | extra="ignore",
19 | )
20 |
21 | track_modifications: bool = False
22 | echo: bool = False
23 | autocommit: bool = False
24 | autoflush: bool = False
25 |
26 |
27 | class AwsS3Settings(BaseSettings):
28 | model_config = SettingsConfigDict(
29 | env_prefix="s3_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
30 | )
31 |
32 | bucket_name: str
33 | access_key: str
34 | secret_access_key: str
35 |
36 | @computed_field(repr=False)
37 | @property
38 | def location(self) -> str:
39 | return f"http://{self.bucket}.s3.amazonaws.com/"
40 |
41 |
42 | class Settings(BaseSettings):
43 | model_config = SettingsConfigDict(
44 | env_file=".env", env_file_encoding="utf-8", extra="ignore"
45 | )
46 |
47 | environment: EnvEnum = EnvEnum.dev
48 | secret_key: str
49 | database_url: PostgresDsn
50 |
51 | open_cage_api_key: str
52 |
53 | sqlalchemy_settings: SqlalchemySettings = SqlalchemySettings()
54 | aws_s3_settings: AwsS3Settings = AwsS3Settings()
55 |
56 | @property
57 | def is_dev(self) -> bool:
58 | cond = self.environment is EnvEnum.dev
59 | return cond
60 |
61 |
62 | settings = Settings()
63 |
--------------------------------------------------------------------------------
/app/seeds/__init__.py:
--------------------------------------------------------------------------------
1 | from flask.cli import AppGroup
2 |
3 | from app.seeds.comment_ratings import seed_comment_ratings, undo_comment_ratings
4 | from app.seeds.comments import seed_comments, undo_comments
5 | from app.seeds.communities import seed_communities, undo_communities
6 | from app.seeds.meetups import seed_meetups, undo_meetups
7 | from app.seeds.post_images import seed_post_images, undo_post_images
8 | from app.seeds.post_ratings import seed_post_ratings, undo_post_ratings
9 | from app.seeds.posts import seed_posts, undo_posts
10 | from app.seeds.retailer_ratings import seed_retailer_ratings, undo_retailer_ratings
11 | from app.seeds.retailers import seed_retailers, undo_retailers
12 | from app.seeds.threads import seed_threads, undo_threads
13 | from app.seeds.users import seed_users, undo_users
14 |
15 | # Creates a seed group to hold our commands
16 | # So we can type `flask seed --help`
17 | seed_commands = AppGroup("seed")
18 |
19 |
20 | # Creates the `flask seed all` command
21 | @seed_commands.command("all")
22 | def seed():
23 | seed_users()
24 | seed_communities()
25 | seed_posts()
26 | seed_threads()
27 | seed_comments()
28 | seed_retailers()
29 | seed_retailer_ratings()
30 | seed_post_images()
31 | seed_meetups()
32 | seed_post_ratings()
33 | seed_comment_ratings()
34 | # Add other seed functions here
35 |
36 |
37 | # Creates the `flask seed undo` command
38 | @seed_commands.command("undo")
39 | def undo():
40 | undo_comment_ratings()
41 | undo_post_ratings()
42 | undo_meetups()
43 | undo_post_images()
44 | undo_retailer_ratings()
45 | undo_retailers()
46 | undo_comments()
47 | undo_threads()
48 | undo_posts()
49 | undo_communities()
50 | undo_users()
51 | # Add other undo functions here
52 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/Upvote/Upvote.js:
--------------------------------------------------------------------------------
1 | import { useSelector, useDispatch } from 'react-redux';
2 |
3 | import { ArrowUpward } from '@mui/icons-material';
4 |
5 | import { session } from '../../../store/selectors';
6 | import { ratePost } from '../../../store/posts';
7 | import { rateComment } from '../../../store/comments';
8 |
9 | const Upvote = ({ id, type, rating }) => {
10 | const dispatch = useDispatch();
11 | const user = useSelector(session.user());
12 |
13 | const onUpvote = () => {
14 | switch (type) {
15 | case 'post':
16 | dispatch(ratePost({ userId: user.id, postId: id, rating: 1 }));
17 | break;
18 | case 'comment':
19 | dispatch(rateComment({ userId: user.id, commentId: id, rating: 1 }));
20 | break;
21 | default:
22 | break;
23 | }
24 | };
25 |
26 | const onUnUpvote = () => {
27 | switch (type) {
28 | case 'post':
29 | dispatch(ratePost({ userId: user.id, postId: id, rating: 0 }));
30 | break;
31 | case 'comment':
32 | dispatch(rateComment({ userId: user.id, commentId: id, rating: 0 }));
33 | break;
34 | default:
35 | break;
36 | }
37 | };
38 |
39 | return (
40 | <>
41 | {rating === 1 ? (
42 |
48 | ) : (
49 |
55 | )}
56 | >
57 | );
58 | };
59 |
60 | export default Upvote;
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Qwerkey
2 |
3 |
4 |
5 |
6 |
7 | ## Live Links
8 |
9 | - [Live link](https://qwerkey.onrender.com)
10 | - [Feature List](https://github.com/Lazytangent/Qwerkey/wiki/MVP-Features-List)
11 |
12 | ## Technologies
13 |
14 | - React.js
15 | - Redux
16 | - JavaScript
17 | - Python
18 | - Flask
19 | - SQLAlchemy
20 | - Alembic
21 | - PostgreSQL
22 | - [OpenCageData](https://opencagedata.com)
23 | - [Leaflet](https://leafletjs.com)
24 | - [React Leaflet](https://react-leaflet.js.org)
25 | - [TailwindCSS](https://tailwindcss.com)
26 |
27 | ## What is it?
28 |
29 | Qwerkey is a social media site for connecting with other mechanical keyboard enthusiasts.
30 |
31 | ## Developing
32 |
33 | ### What you'll need on your machine:
34 |
35 | - PostgreSQL
36 | - Pipenv with Python v3.8
37 | - Node.js
38 |
39 | 1. `git clone` this repo
40 | 2. `cd` into the local repo
41 | 3. Run `pipenv install -r --dev dev-requirements.txt && pipenv install -r requirements.txt`
42 | 4. Create your own `.env` file based on the provided `.env.example`.
43 | 5. Create a user and database in your PostgreSQL that matches your `.env` configuration
44 | 6. In the first terminal, run `pipenv shell` to activate the Pipenv environment.
45 | 7. Run `flask db upgrade` and then `flask seed all` to apply migrations and seed data to your database.
46 | 8. Open another terminal window and `cd` into the local repo, then `cd` into `react-app`
47 | 9. Run `npm install`
48 | 10. In your terminal running Pipenv shell, run `flask run`.
49 | 11. In your terminal in the `react-app`, run `npm start`.
50 | 12. Your app should open in your default browser.
51 | 13. If you are planning on developing, please make a fork and create pull requests as necessary.
52 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/DeleteConfirmation/DeleteConfirmation.js:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 |
3 | import { deletePost } from '../../../store/posts';
4 | import { deleteComment } from '../../../store/comments';
5 | import { deleteRetailerRating } from '../../../store/retailers';
6 | import { deleteMeetup } from '../../../store/meetups';
7 | import DeleteButton from '../DeleteButton';
8 | import EditButton from '../EditButton';
9 |
10 | const DeleteConfirmation = ({ setShowDeleteModal, id, extraId, type }) => {
11 | const dispatch = useDispatch();
12 |
13 | const deleteHandler = () => {
14 | switch (type) {
15 | case 'post':
16 | dispatch(deletePost(id));
17 | break;
18 | case 'comment':
19 | dispatch(deleteComment(id));
20 | break;
21 | case 'retailerRating':
22 | dispatch(deleteRetailerRating(id, extraId));
23 | break;
24 | case 'meetup':
25 | dispatch(deleteMeetup(id));
26 | break;
27 | default:
28 | break;
29 | }
30 | setShowDeleteModal(false);
31 | };
32 |
33 | const cancelDeleteHandler = () => {
34 | setShowDeleteModal(false);
35 | };
36 |
37 | return (
38 |
39 |
40 |
Are you sure?
41 |
42 |
43 |
48 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default DeleteConfirmation;
59 |
--------------------------------------------------------------------------------
/react-app/src/store/constants.js:
--------------------------------------------------------------------------------
1 | // Session Constants
2 | export const SET_SESSION = 'session/SET_SESSION';
3 | export const REMOVE_SESSION = 'session/REMOVE_SESSION';
4 |
5 | // Posts Constants
6 | export const SET_MORE_POSTS = 'posts/SET_MORE_POSTS';
7 | export const SET_POSTS = 'posts/SET_POSTS';
8 | export const SET_POST = 'posts/SET_POST';
9 | export const SET_MAX_POSTS = 'posts/SET_MAX';
10 | export const SET_ORDER = 'posts/SET_ORDER';
11 |
12 | // Users Constants
13 | export const SET_MORE_USERS = 'users/SET_MORE_USERS';
14 | export const SET_USERS = 'users/SET_USERS';
15 | export const SET_USER = 'users/SET_USER';
16 | export const SET_MAX_USERS = 'users/SET_MAX';
17 |
18 | // Comments Constants
19 | export const SET_COMMENTS = 'comments/SET_COMMENTS';
20 | export const SET_COMMENT = 'comments/SET_COMMENT';
21 |
22 | // Retailers Constants
23 | export const REMOVE_RETAILER = 'retailers/REMOVE_RETAILER';
24 | export const SET_MORE_RETAILERS = 'retailers/SET_MORE_RETAILERS';
25 | export const SET_RETAILERS = 'retailers/SET_RETAILERS';
26 | export const SET_RETAILER = 'retailers/SET_RETAILER';
27 | export const SET_MAX_RETAILERS = 'retailers/SET_MAX';
28 |
29 | // Meetups Constants
30 | export const SET_MAX_MEETUPS = 'meetups/SET_MAX';
31 | export const SET_MORE_MEETUPS = 'meetups/SET_MORE_MEETUPS';
32 | export const SET_MEETUPS = 'meetups/SET_MEETUPS';
33 | export const SET_MEETUP = 'meetups/SET_MEETUP';
34 | export const REMOVE_MEETUP = 'meetups/REMOVE_MEETUP';
35 |
36 | // Search Constants
37 | export const SET_SEARCH = 'search/setSearch';
38 |
39 | // Communities Constants
40 | export const SET_COMMUNITIES = 'communities/SET_COMMUNITIES';
41 | export const SET_COMMUNITY = 'communities/SET_COMMUNITY';
42 |
43 | // Sidebar Constants
44 | export const SET_SIDEBAR_COMMUNITIES = 'sidebar/SET_COMMUNITIES';
45 | export const SET_SIDEBAR_COMMUNITY = 'sidebar/SET_COMMUNITY';
46 |
--------------------------------------------------------------------------------
/react-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/react": "^11.11.1",
7 | "@emotion/styled": "^11.11.0",
8 | "@headlessui/react": "^0.3.1",
9 | "@mui/icons-material": "^5.11.16",
10 | "@mui/material": "^5.13.6",
11 | "@mui/styles": "^5.13.2",
12 | "@reduxjs/toolkit": "^1.9.5",
13 | "@testing-library/jest-dom": "^5.11.9",
14 | "@testing-library/react": "^11.2.5",
15 | "@testing-library/user-event": "^12.8.1",
16 | "caniuse-lite": "^1.0.30001512",
17 | "country-state-city": "^2.0.0",
18 | "js-cookie": "^3.0.1",
19 | "leaflet": "^1.7.1",
20 | "react": "^18.0.0",
21 | "react-dom": "^18.0.0",
22 | "react-leaflet": "^3.1.0",
23 | "react-redux": "^7.2.2",
24 | "react-router-dom": "^6.3.0",
25 | "react-scripts": "^5.0.0",
26 | "redux": "^4.0.5",
27 | "uuid": "^8.3.2",
28 | "web-vitals": "^1.1.0"
29 | },
30 | "scripts": {
31 | "start": "react-scripts start",
32 | "build": "react-scripts build",
33 | "test": "react-scripts test",
34 | "eject": "react-scripts eject"
35 | },
36 | "eslintConfig": {
37 | "extends": [
38 | "react-app",
39 | "react-app/jest"
40 | ]
41 | },
42 | "browserslist": {
43 | "production": [
44 | ">0.2%",
45 | "not dead",
46 | "not op_mini all"
47 | ],
48 | "development": [
49 | "last 1 chrome version",
50 | "last 1 firefox version",
51 | "last 1 safari version"
52 | ]
53 | },
54 | "devDependencies": {
55 | "autoprefixer": "^10.4.0",
56 | "babel-plugin-import": "^1.13.3",
57 | "postcss": "^8.4.5",
58 | "redux-logger": "^3.0.6",
59 | "tailwindcss": "^3.0.7"
60 | },
61 | "jest": {
62 | "globalSetup": "./global-setup.js"
63 | },
64 | "proxy": "http://127.0.0.1:5000"
65 | }
66 |
--------------------------------------------------------------------------------
/react-app/src/components/parts/Downvote/Downvote.js:
--------------------------------------------------------------------------------
1 | import { useSelector, useDispatch } from 'react-redux';
2 |
3 | import { ArrowDownward } from '@mui/icons-material';
4 |
5 | import { session } from '../../../store/selectors';
6 | import { ratePost } from '../../../store/posts';
7 | import { rateComment } from '../../../store/comments';
8 |
9 | const Downvote = ({ id, type, rating }) => {
10 | const dispatch = useDispatch();
11 | const user = useSelector(session.user());
12 |
13 | const onDownvote = () => {
14 | switch (type) {
15 | case 'post':
16 | dispatch(ratePost({ userId: user.id, postId: id, rating: -1 }));
17 | break;
18 | case 'comment':
19 | dispatch(rateComment({ userId: user.id, commentId: id, rating: -1 }));
20 | break;
21 | default:
22 | break;
23 | }
24 | };
25 |
26 | const onUnDownvote = () => {
27 | switch (type) {
28 | case 'post':
29 | dispatch(ratePost({ userId: user.id, postId: id, rating: 0 }));
30 | break;
31 | case 'comment':
32 | dispatch(rateComment({ userId: user.id, commentId: id, rating: 0 }));
33 | break;
34 | default:
35 | break;
36 | }
37 | };
38 |
39 | return (
40 | <>
41 | {rating === -1 ? (
42 |
48 | ) : (
49 |
55 | )}
56 | >
57 | );
58 | };
59 |
60 | export default Downvote;
61 |
--------------------------------------------------------------------------------
/react-app/src/components/__tests__/Post.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { Provider } from 'react-redux';
3 | import { BrowserRouter } from 'react-router-dom';
4 |
5 | import { store } from '../../store';
6 | import Post from '../Post';
7 |
8 | describe('The Post component', () => {
9 | describe('renders', () => {
10 | beforeEach(() => {
11 | const post = {
12 | id: 1,
13 | title: 'Test post',
14 | body: 'Test post body',
15 | created_at: 'Tue, 02 March 2021 02:06:09 GMT',
16 | images: [],
17 | tags: ['Good', 'New'],
18 | community: {
19 | name: 'Test Community',
20 | },
21 | user: {
22 | id: 1,
23 | name: 'testuser',
24 | },
25 | };
26 |
27 | render(
28 |
29 |
30 |
31 |
32 |
33 | );
34 | });
35 |
36 | test('the Post title', () => {
37 | const title = screen.getByText('Test post');
38 | expect(title).toHaveTextContent('Test post');
39 | });
40 |
41 | test('the Post body', () => {
42 | const body = screen.getByText('Test post body');
43 | expect(body).toHaveTextContent('Test post body');
44 | });
45 |
46 | test('the Post timestamp', () => {
47 | const body = screen.queryByText('Tuesday, March 2, 2021, 02:06:09 AM');
48 | if (body === null) {
49 | return;
50 | }
51 | expect(body).toHaveTextContent('Tuesday, March 2, 2021, 02:06:09 AM');
52 | });
53 |
54 | test('the Post tags', () => {
55 | const expectedTags = ['Good', 'New'];
56 | expectedTags.forEach((tag) => {
57 | const tagText = screen.getByText(tag);
58 | expect(tagText).not.toBeNull();
59 | });
60 | });
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerPage/RetailerPage.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { useParams } from 'react-router-dom';
4 |
5 | import { session, retailers } from '../../store/selectors';
6 | import { getOneRetailer, getOneRetailerLocation } from '../../store/retailers';
7 | import Retailer from '../Retailer';
8 | import RetailerRatingsContainer from '../RetailerRatingsContainer';
9 | import RetailerRatingForm from '../RetailerRatingForm';
10 | import Map from '../Map';
11 |
12 | const RetailerPage = () => {
13 | const { retailerId } = useParams();
14 | const dispatch = useDispatch();
15 | const retailer = useSelector(retailers.byId(retailerId));
16 | const user = useSelector(session.user());
17 |
18 | const [isLoaded, setIsLoaded] = useState(false);
19 |
20 | useEffect(() => {
21 | dispatch(getOneRetailer(retailerId));
22 | }, [dispatch, retailerId]);
23 |
24 | useEffect(() => {
25 | (async () => {
26 | if (retailer) {
27 | if (!(retailer.lng || retailer.lat)) {
28 | await dispatch(getOneRetailerLocation(retailerId));
29 | }
30 | setIsLoaded(true);
31 | }
32 | })();
33 | }, [retailer, retailerId, dispatch]);
34 |
35 | if (!isLoaded) {
36 | return null;
37 | }
38 |
39 | return (
40 | <>
41 | {retailer && (
42 | <>
43 |
44 | {retailer.lat && retailer.lng && (
45 |
46 | )}
47 | {user &&
48 | retailer.owner.id !== user.id &&
49 | !(user.id in retailer.ratings) && (
50 |
51 | )}
52 |
53 | >
54 | )}
55 | >
56 | );
57 | };
58 |
59 | export default RetailerPage;
60 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | -i https://pypi.org/simple
2 | alembic==1.11.1
3 | annotated-types==0.5.0 ; python_version >= '3.7'
4 | anyio==3.7.0 ; python_version >= '3.7'
5 | attrs==20.3.0
6 | blinker==1.6.2 ; python_version >= '3.7'
7 | boto3==1.17.26
8 | botocore==1.20.26
9 | certifi==2020.12.5
10 | chardet==4.0.0
11 | click==8.1.3
12 | dnspython==2.1.0
13 | email-validator==2.0.0.post2
14 | fastapi[all]==0.100.0-beta2
15 | flask==2.3.2
16 | flask-cors==3.0.8
17 | flask-jwt-extended==3.24.1
18 | flask-login==0.6.2
19 | flask-migrate==4.0.4
20 | flask-sqlalchemy==3.0.5
21 | flask-wtf==1.1.1
22 | greenlet==2.0.2 ; platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))
23 | gunicorn==20.0.4
24 | h11==0.14.0 ; python_version >= '3.7'
25 | httpcore==0.17.2 ; python_version >= '3.7'
26 | httptools==0.5.0
27 | httpx==0.24.1
28 | idna==2.10
29 | iniconfig==1.1.1
30 | itsdangerous==2.1.2
31 | jinja2==3.1.2
32 | jmespath==0.10.0
33 | mako==1.1.3
34 | markupsafe==2.1.3
35 | orjson==3.9.1
36 | packaging==23.1 ; python_version >= '3.7'
37 | pluggy==0.13.1
38 | pydantic==v2.0
39 | pydantic-core==2.0.1 ; python_version >= '3.7'
40 | pydantic-settings==2.0
41 | pyjwt==1.7.1
42 | pyparsing==2.4.7
43 | pytest==7.4.0 ; python_version >= '3.7'
44 | pytest-dotenv==0.5.2
45 | python-dateutil==2.8.1
46 | python-dotenv==1.0
47 | python-editor==1.0.4
48 | python-multipart==0.0.6
49 | pyyaml==6.0
50 | requests==2.25.1
51 | s3transfer==0.3.4
52 | setuptools==68.0.0 ; python_version >= '3.7'
53 | six==1.15.0
54 | sniffio==1.3.0 ; python_version >= '3.7'
55 | sqlalchemy==2.0.17
56 | sqlalchemy-utils==0.36.8
57 | starlette==0.27.0 ; python_version >= '3.7'
58 | toml==0.10.2
59 | typing-extensions==4.7.1 ; python_version >= '3.7'
60 | ujson==5.8.0
61 | urllib3==1.26.3
62 | uvicorn==0.22.0
63 | uvloop==0.17.0
64 | watchfiles==0.19.0
65 | websockets==11.0.3
66 | werkzeug==2.3.6
67 | wtforms==2.3.3
68 |
--------------------------------------------------------------------------------
/app/routes/api/comment.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, request
2 |
3 | from app.forms import CreateComment, CreateCommentRating
4 | from app.helpers import validation_errors_to_error_messages
5 | from app.models import Comment, CommentRating, db
6 |
7 | comment = Blueprint("comments", __name__)
8 |
9 |
10 | @comment.route("/", methods=["PUT", "DELETE"])
11 | def update_comment(comment_id):
12 | comment = Comment.query.get(comment_id)
13 | if request.method == "PUT":
14 | form = CreateComment()
15 | if form.validate_on_submit():
16 | form["thread_id"].data = comment.thread_id
17 | form["path"].data = comment.path
18 | form["level"].data = comment.level
19 | form.populate_obj(comment)
20 | db.session.commit()
21 | return comment.to_dict()
22 | return {"errors": validation_errors_to_error_messages(form.errors)}
23 | elif request.method == "DELETE":
24 | comment.body = "[DELETED]"
25 | db.session.commit()
26 | return comment.to_dict()
27 | return "Bad route", 404
28 |
29 |
30 | @comment.route("//rating", methods=["POST"])
31 | def rate_comment(comment_id):
32 | comment = Comment.query.get(comment_id)
33 | form = CreateCommentRating()
34 | if form.validate_on_submit():
35 | user_id = form["user_id"].data
36 | comment_rating = CommentRating.query.filter(
37 | CommentRating.user_id == user_id, CommentRating.comment_id == comment_id
38 | ).first()
39 | if comment_rating:
40 | comment_rating.rating = form["rating"].data
41 | db.session.commit()
42 | return comment.to_dict()
43 | comment_rating = CommentRating()
44 | form.populate_obj(comment_rating)
45 | comment_rating.comment_id = comment_id
46 | db.session.add(comment_rating)
47 | db.session.commit()
48 | return comment.to_dict()
49 | return {"errors": validation_errors_to_error_messages(form.errors)}
50 |
--------------------------------------------------------------------------------
/react-app/src/store/users.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_MORE_USERS,
3 | SET_USERS,
4 | SET_USER,
5 | SET_MAX_USERS,
6 | } from './constants';
7 | import {
8 | setMaxNumberOfUsers,
9 | setUser,
10 | setUsers,
11 | setMoreUsers,
12 | } from './actions';
13 | import api from './api';
14 |
15 | export const getUsers = (page) => async (dispatch) => {
16 | const res = await api(`/api/users?page=${page}`);
17 | const users = await res.json();
18 | if (page === 1) {
19 | dispatch(setUsers(users));
20 | } else {
21 | dispatch(setMoreUsers(users));
22 | }
23 | return users;
24 | };
25 |
26 | export const getUser = (userId) => async (dispatch) => {
27 | const res = await api(`/api/users/${userId}`);
28 | const user = await res.json();
29 | if (!user.errors) {
30 | dispatch(setUser(user));
31 | }
32 | return user;
33 | };
34 |
35 | export const getMaxNumberOfUsers = () => async (dispatch) => {
36 | const res = await api('/api/users/max');
37 | const number = await res.json();
38 | dispatch(setMaxNumberOfUsers(number.max));
39 | return number;
40 | };
41 |
42 | const initialState = {
43 | byIds: {},
44 | max: null,
45 | };
46 |
47 | const usersReducer = (state = initialState, action) => {
48 | switch (action.type) {
49 | case SET_USER:
50 | const user = {
51 | ...action.user,
52 | posts: action.user.posts.map((post) => post.id),
53 | comments: action.user.comments.map((comment) => comment.id),
54 | retailers: action.user.retailers.map((retailer) => retailer.id),
55 | meetups: action.user.meetups.map((meetup) => meetup.id),
56 | };
57 | return { ...state, byIds: { ...state.byIds, [action.user.id]: user } };
58 | case SET_USERS:
59 | return { ...state, byIds: { ...action.users } };
60 | case SET_MORE_USERS:
61 | return { ...state, byIds: { ...state.byIds, ...action.users } };
62 | case SET_MAX_USERS:
63 | return { ...state, max: action.number };
64 | default:
65 | return state;
66 | }
67 | };
68 |
69 | export default usersReducer;
70 |
--------------------------------------------------------------------------------
/react-app/src/components/CreateCommunityForm/CreateCommunityForm.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import { createCommunity } from '../../store/communities';
6 | import FormTitle from '../parts/FormTitle';
7 | import FormErrors from '../parts/FormErrors';
8 | import InputField from '../parts/InputField';
9 | import SubmitFormButton from '../parts/SubmitFormButton';
10 |
11 | const CreateCommunityForm = () => {
12 | const navigate = useNavigate();
13 | const dispatch = useDispatch();
14 |
15 | const [name, setName] = useState('');
16 | const [description, setDescription] = useState('');
17 | const [errors, setErrors] = useState([]);
18 |
19 | const updateName = (e) => {
20 | setName(e.target.value);
21 | };
22 |
23 | const updateDescription = (e) => {
24 | setDescription(e.target.value);
25 | };
26 |
27 | const submitHandler = async (e) => {
28 | e.preventDefault();
29 | const community = {
30 | name,
31 | description,
32 | };
33 | const newCommunity = await dispatch(createCommunity(community));
34 | if (newCommunity.errors) {
35 | setErrors(newCommunity.errors);
36 | } else {
37 | navigate(`/q/${newCommunity.name}`);
38 | }
39 | };
40 |
41 | return (
42 | <>
43 |
64 | >
65 | );
66 | };
67 |
68 | export default CreateCommunityForm;
69 |
--------------------------------------------------------------------------------
/react-app/src/store/selectors.js:
--------------------------------------------------------------------------------
1 | export const session = {
2 | user: () => (state) => state.session.user,
3 | };
4 |
5 | export const users = {
6 | byId: (id) => (state) => state.users.byIds[id],
7 | };
8 |
9 | export const posts = {
10 | byId: (id) => (state) => state.posts.posts[id],
11 | byUser: (user) => (state) =>
12 | user ? user.posts.map((id) => state.posts.posts[id]) : [],
13 | all: () => (state) => state.posts.posts,
14 | max: () => (state) => state.posts.max,
15 | order: () => (state) => state.posts.order,
16 | };
17 |
18 | export const comments = {
19 | byUser: (user) => (state) =>
20 | user ? user.comments.map((id) => state.comments.comments[id]) : [],
21 | all: () => (state) => state.comments.comments,
22 | };
23 |
24 | export const retailers = {
25 | byId: (id) => (state) => state.retailers.retailers[id],
26 | byUser: (user) => (state) =>
27 | user ? user.retailers.map((id) => state.retailers.retailers[id]) : [],
28 | all: () => (state) => state.retailers.retailers,
29 | max: () => (state) => state.retailers.max,
30 | };
31 |
32 | export const meetups = {
33 | byId: (id) => (state) => state.meetups.meetups[id],
34 | byUser: (user) => (state) =>
35 | user ? user.meetups.map((id) => state.meetups.meetups[id]) : [],
36 | all: () => (state) => state.meetups.meetups,
37 | max: () => (state) => state.meetups.max,
38 | };
39 |
40 | export const communities = {
41 | all: () => (state) => state.communities,
42 | };
43 |
44 | export const sidebar = {
45 | popularCommunities: () => (state) =>
46 | state.sidebar.popular.map((id) => state.communities[id]),
47 | currentCommunity: () => (state) => state.communities[state.sidebar.community],
48 | };
49 |
50 | export const search = {
51 | results: () => (state) => state.search,
52 | posts:
53 | ({ posts = [] }) =>
54 | (state) =>
55 | posts.map((id) => state.posts.posts[id]),
56 | comments:
57 | ({ comments = [] }) =>
58 | (state) =>
59 | comments.map((id) => state.comments.comments[id]),
60 | retailers:
61 | ({ retailers = [] }) =>
62 | (state) =>
63 | retailers.map((id) => state.retailers.retailers[id]),
64 | };
65 |
--------------------------------------------------------------------------------
/app/models/retailer.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.schemas.user import MinimalUserResponse
5 |
6 |
7 | class Retailer(db.Model):
8 | __tablename__ = "retailers"
9 |
10 | id = db.Column(db.Integer, primary_key=True)
11 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
12 | name = db.Column(db.String(50), nullable=False)
13 | description = db.Column(db.Text, nullable=False)
14 | city = db.Column(db.String(50), nullable=False)
15 | state = db.Column(db.String(25), nullable=False)
16 | lat = db.Column(db.Numeric(scale=7))
17 | lng = db.Column(db.Numeric(scale=7))
18 | created_at = db.Column(
19 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
20 | )
21 | updated_at = db.Column(
22 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
23 | )
24 |
25 | user = db.relationship("User", back_populates="retailers")
26 | images = db.relationship("RetailerImage", back_populates="retailer")
27 | ratings = db.relationship("RetailerRating", back_populates="retailer")
28 |
29 | def to_dict(self):
30 | return {
31 | "id": self.id,
32 | "owner": MinimalUserResponse.from_orm(self.user).dict(),
33 | "name": self.name,
34 | "description": self.description,
35 | "city": self.city,
36 | "state": self.state,
37 | "lat": float(self.lat or 0),
38 | "lng": float(self.lng or 0),
39 | "created_at": self.created_at,
40 | "images": [image.image_url for image in self.images],
41 | "ratings": {rating.user_id: rating.to_dict() for rating in self.ratings},
42 | }
43 |
44 | def to_simple_dict(self):
45 | return {
46 | "id": self.id,
47 | "owner": MinimalUserResponse.from_orm(self.user).dict(),
48 | "name": self.name,
49 | "description": self.description,
50 | "city": self.city,
51 | "state": self.state,
52 | "created_at": self.created_at,
53 | "ratings": [rating.to_dict() for rating in self.ratings],
54 | }
55 |
--------------------------------------------------------------------------------
/react-app/src/components/Sidebar/Sidebar.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { NavLink, useLocation, useParams } from 'react-router-dom';
4 |
5 | import { sidebar } from '../../store/selectors';
6 | import {
7 | getSidebarCommunity,
8 | getSidebarPopularCommunities,
9 | } from '../../store/sidebar';
10 | import About from '../About';
11 |
12 | const Sidebar = () => {
13 | const location = useLocation();
14 | const { communityName } = useParams();
15 |
16 | const dispatch = useDispatch();
17 | const popularCommunities = useSelector(sidebar.popularCommunities());
18 | const currentCommunity = useSelector(sidebar.currentCommunity());
19 |
20 | const [isLoaded, setIsLoaded] = useState(false);
21 |
22 | useEffect(() => {
23 | dispatch(getSidebarPopularCommunities());
24 | }, [dispatch]);
25 |
26 | useEffect(() => {
27 | if (communityName && !currentCommunity) {
28 | dispatch(getSidebarCommunity(communityName));
29 | }
30 | }, [dispatch, communityName, currentCommunity]);
31 |
32 | useEffect(() => {
33 | if (popularCommunities) {
34 | setIsLoaded(true);
35 | }
36 | }, [popularCommunities]);
37 |
38 | if (!isLoaded) {
39 | return null;
40 | }
41 |
42 | return (
43 |
44 | {location.pathname.startsWith('/q') && currentCommunity && (
45 |
46 |
{currentCommunity.name}
47 |
{currentCommunity.description}
48 |
49 | )}
50 |
51 |
Top 5 Communities
52 | {popularCommunities.map((community) => (
53 |
54 |
55 | {community.name}
56 |
57 |
58 | ))}
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default Sidebar;
66 |
--------------------------------------------------------------------------------
/react-app/src/components/EditRetailerRatingForm/EditRetailerRatingForm.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 |
4 | import { session } from '../../store/selectors';
5 | import { updateRetailerRating } from '../../store/retailers';
6 | import { useRetailerRatingContext } from '../../context/RetailerRatingContext';
7 | import FormTitle from '../parts/FormTitle';
8 | import InputField from '../parts/InputField';
9 | import SubmitFormButton from '../parts/SubmitFormButton';
10 | import convertFormErrors from '../../utils/convertFormErrors';
11 |
12 | const EditRetailerRatingForm = ({ setShowEditModal }) => {
13 | const dispatch = useDispatch();
14 | const user = useSelector(session.user());
15 |
16 | const { retailerRating } = useRetailerRatingContext();
17 | const [rating, setRating] = useState(retailerRating.rating);
18 | const [errors, setErrors] = useState([]);
19 |
20 | const updateRating = (e) => {
21 | setRating(e.target.value);
22 | };
23 |
24 | const submitHandler = async (e) => {
25 | e.preventDefault();
26 | const updatedRating = {
27 | rating,
28 | user_id: user.id,
29 | id: retailerRating.id,
30 | };
31 | const retailer = await dispatch(
32 | updateRetailerRating(updatedRating, retailerRating.retailer_id)
33 | );
34 | if (!retailer.errors) {
35 | setShowEditModal(false);
36 | } else {
37 | const newErrors = convertFormErrors(retailer.errors);
38 | setErrors(newErrors);
39 | }
40 | };
41 |
42 | return (
43 |
63 | );
64 | };
65 |
66 | export default EditRetailerRatingForm;
67 |
--------------------------------------------------------------------------------
/app/models/user.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import Type, TypeVar
3 |
4 | from flask_login import UserMixin
5 | from werkzeug.security import check_password_hash, generate_password_hash
6 |
7 | from app.models.db import db
8 | from app.models.saved_comment import saved_comments
9 | from app.models.saved_post import saved_posts
10 |
11 | T = TypeVar("T", bound="User")
12 |
13 |
14 | class User(db.Model, UserMixin):
15 | __tablename__ = "users"
16 |
17 | id = db.Column(db.Integer, primary_key=True)
18 | username = db.Column(db.String(40), nullable=False, unique=True)
19 | email = db.Column(db.String(255), nullable=False, unique=True)
20 | hashed_password = db.Column(db.String(255), nullable=False)
21 | created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
22 |
23 | posts = db.relationship("Post", back_populates="user")
24 | comments = db.relationship("Comment", back_populates="user")
25 | retailers = db.relationship("Retailer", back_populates="user")
26 | rated_posts = db.relationship("PostRating", back_populates="user")
27 | rated_comments = db.relationship("CommentRating", back_populates="user")
28 | saved_posts = db.relationship("Post", secondary=saved_posts)
29 | saved_comments = db.relationship("Comment", secondary=saved_comments)
30 | meetups = db.relationship("Meetup", back_populates="user")
31 | sent_messages = db.relationship(
32 | "Message", foreign_keys="Message.sender_id", back_populates="sender"
33 | )
34 | received_messages = db.relationship(
35 | "Message", foreign_keys="Message.recipient_id", back_populates="recipient"
36 | )
37 |
38 | @property
39 | def password(self) -> str:
40 | return self.hashed_password
41 |
42 | @password.setter
43 | def password(self, password: str) -> None:
44 | self.hashed_password = generate_password_hash(password)
45 |
46 | def check_password(self, password: str) -> bool:
47 | return check_password_hash(self.password, password)
48 |
49 | def __repr__(self):
50 | return f""
51 |
52 | @classmethod
53 | def create(cls: Type[T], username, email, password) -> T:
54 | user = cls(username=username, email=email, password=password)
55 | db.session.add(user)
56 | db.session.commit()
57 | return user
58 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailerRating/RetailerRating.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { NavLink } from 'react-router-dom';
4 |
5 | import { session } from '../../store/selectors';
6 | import { useRetailerRatingContext } from '../../context/RetailerRatingContext';
7 | import EditButton from '../parts/EditButton';
8 | import EditRetailerRatingModal from '../EditRetailerRatingForm';
9 | import DeleteButton from '../parts/DeleteButton';
10 | import DeleteConfirmationModal from '../parts/DeleteConfirmation';
11 |
12 | const RetailerRating = ({ rating }) => {
13 | const user = useSelector(session.user());
14 |
15 | const [showEditModal, setShowEditModal] = useState(false);
16 | const [showDeleteModal, setShowDeleteModal] = useState(false);
17 | const { setRetailerRating } = useRetailerRatingContext();
18 |
19 | const editBtnHandler = () => {
20 | setShowEditModal(true);
21 | setRetailerRating(rating);
22 | };
23 |
24 | const deleteBtnHandler = () => {
25 | setShowDeleteModal(true);
26 | };
27 |
28 | return (
29 |
30 |
31 | Rating by{' '}
32 |
33 |
34 | {rating.user.username}
35 |
36 |
37 |
38 |
{rating.rating}
39 | {rating.user.id === user?.id && (
40 | <>
41 |
42 |
46 |
47 |
48 |
55 |
56 | >
57 | )}
58 |
59 | );
60 | };
61 |
62 | export default RetailerRating;
63 |
--------------------------------------------------------------------------------
/react-app/src/components/EditCommentForm/EditCommentForm.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import { useDispatch } from 'react-redux';
3 |
4 | import { updateComment } from '../../store/comments';
5 | import { useCommentContext } from '../../context/CommentContext';
6 | import FormTitle from '../parts/FormTitle';
7 | import SubmitFormButton from '../parts/SubmitFormButton';
8 |
9 | const EditCommentForm = ({ setShowEditModal }) => {
10 | const dispatch = useDispatch();
11 |
12 | const { comment } = useCommentContext();
13 |
14 | const [body, setBody] = useState(comment.body);
15 | const [errors, setErrors] = useState([]);
16 | const textAreaRef = useRef();
17 |
18 | useEffect(() => {
19 | const commentLength = body.length;
20 | textAreaRef.current.focus();
21 | textAreaRef.current.setSelectionRange(commentLength, commentLength);
22 | }, [body.length]);
23 |
24 | const updateBody = (e) => {
25 | setBody(e.target.value);
26 | };
27 |
28 | const submitHandler = async (e) => {
29 | e.preventDefault();
30 | const updatedComment = {
31 | id: comment.id,
32 | body,
33 | user_id: comment.user.id,
34 | };
35 | const post = await dispatch(updateComment(updatedComment));
36 | if (!post.errors) {
37 | setBody('');
38 | setShowEditModal(false);
39 | } else {
40 | setErrors(['A body is required to make a comment.']);
41 | }
42 | };
43 |
44 | return (
45 |
68 | );
69 | };
70 |
71 | export default EditCommentForm;
72 |
--------------------------------------------------------------------------------
/react-app/src/components/RetailersContainer/RetailersContainer.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { v4 as uuidv4 } from 'uuid';
4 |
5 | import { retailers as retailersSelectors } from '../../store/selectors';
6 | import { getRetailers, getMaxNumberOfRetailers } from '../../store/retailers';
7 | import Retailer from '../Retailer';
8 |
9 | const RetailersContainer = () => {
10 | const dispatch = useDispatch();
11 | const retailers = useSelector(retailersSelectors.all());
12 | const maxRetailers = useSelector(retailersSelectors.max());
13 |
14 | const [page, setPage] = useState(1);
15 | const [currentRetailers, setCurrentRetailers] = useState([]);
16 | const [isLoaded, setIsLoaded] = useState(false);
17 |
18 | useEffect(() => {
19 | window.scrollTo(0, 0);
20 | dispatch(getMaxNumberOfRetailers());
21 | }, [dispatch]);
22 |
23 | useEffect(() => {
24 | if (page * 20 - maxRetailers < 20) {
25 | dispatch(getRetailers(page));
26 | }
27 | }, [dispatch, page, maxRetailers]);
28 |
29 | useEffect(() => {
30 | if (retailers) {
31 | setIsLoaded(true);
32 | setCurrentRetailers(Object.values(retailers));
33 | }
34 | }, [retailers]);
35 |
36 | useEffect(() => {
37 | if (page * 20 > maxRetailers) {
38 | setCurrentRetailers((prev) =>
39 | prev.concat(
40 | Object.values(retailers).slice(0, (page * 20) % maxRetailers)
41 | )
42 | );
43 | }
44 | }, [retailers, maxRetailers, page]);
45 |
46 | useEffect(() => {
47 | const scrollListener = () => {
48 | const scroll =
49 | document.body.scrollTop || document.documentElement.scrollTop;
50 | const height =
51 | document.documentElement.scrollHeight -
52 | document.documentElement.clientHeight;
53 | const scrolled = scroll / height;
54 | if (scrolled > 0.9) {
55 | setPage((prev) => prev + 1);
56 | }
57 | };
58 |
59 | window.addEventListener('scroll', scrollListener);
60 | return () => window.removeEventListener('scroll', scrollListener);
61 | }, [page, maxRetailers, retailers]);
62 |
63 | if (!isLoaded) {
64 | return null;
65 | }
66 |
67 | return (
68 | <>
69 | {currentRetailers.map((retailer) => (
70 |
71 | ))}
72 | >
73 | );
74 | };
75 |
76 | export default RetailersContainer;
77 |
--------------------------------------------------------------------------------
/react-app/src/components/Meetup/Meetup.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { NavLink } from 'react-router-dom';
4 |
5 | import { session } from '../../store/selectors';
6 | import EditButton from '../parts/EditButton';
7 | import DeleteButton from '../parts/DeleteButton';
8 | import DeleteConfirmationModal from '../parts/DeleteConfirmation';
9 | import EditMeetupModal from '../EditMeetupForm';
10 | import DivCard from '../parts/DivCard';
11 | import UserName from '../parts/UserName';
12 | import options from '../../utils/localeDateString';
13 |
14 | const Meetup = ({ meetup }) => {
15 | const user = useSelector(session.user());
16 |
17 | const [showEditModal, setShowEditModal] = useState(false);
18 | const [showDeleteModal, setShowDeleteModal] = useState(false);
19 |
20 | const editBtnHandler = () => {
21 | setShowEditModal(true);
22 | };
23 |
24 | const deleteBtnHandler = () => {
25 | setShowDeleteModal(true);
26 | };
27 |
28 | return (
29 |
30 |
31 |
32 | {meetup.name}
33 |
34 |
35 | {meetup.description}
36 |
37 |
38 | Organized by{' '}
39 |
43 |
44 |
45 | Scheduled for {new Date(meetup.date).toLocaleString(...options())} in{' '}
46 | {meetup.city}, {meetup.state}
47 |
48 | {user && meetup.user.id === user.id && (
49 |
50 |
51 |
56 |
57 |
58 |
64 |
65 |
66 | )}
67 |
68 | );
69 | };
70 |
71 | export default Meetup;
72 |
--------------------------------------------------------------------------------
/app/routes/api/user.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, request
2 | from flask_login import current_user
3 |
4 | from app.models import Comment, Meetup, Post, Retailer, User, db
5 | from app.schemas.user import FullUserResponse
6 |
7 | user = Blueprint("users", __name__)
8 |
9 |
10 | @user.route("")
11 | def users():
12 | page = int(request.args.get("page", 1))
13 | users = User.query.paginate(page=page, per_page=20)
14 | return {user.id: FullUserResponse.from_orm(user).dict() for user in users.items}
15 |
16 |
17 | @user.route("/")
18 | def user_by_id(id):
19 | user = User.query.get(id)
20 | if user:
21 | return FullUserResponse.from_orm(user).dict()
22 | return {"errors": ["Invalid User."]}
23 |
24 |
25 | @user.route("/max")
26 | def get_max_number_of_users():
27 | number = User.query.count()
28 | return {"max": number}
29 |
30 |
31 | @user.route("//posts")
32 | def get_users_posts(id_):
33 | posts = Post.query.filter(Post.user_id == id_).all()
34 | return {post.id: post.to_dict() for post in posts}
35 |
36 |
37 | @user.route("//comments")
38 | def get_users_comments(id_):
39 | comments = Comment.query.filter(Comment.user_id == id_).all()
40 | return {comment.id: comment.to_dict() for comment in comments}
41 |
42 |
43 | @user.route("//retailers")
44 | def get_users_retailers(id_):
45 | retailers = Retailer.query.filter(Retailer.user_id == id_).all()
46 | return {retailer.id: retailer.to_dict() for retailer in retailers}
47 |
48 |
49 | @user.route("//meetups")
50 | def get_users_meetups(id_):
51 | meetups = Meetup.query.filter(Meetup.user_id == id_).all()
52 | return {meetup.id: meetup.to_dict() for meetup in meetups}
53 |
54 |
55 | @user.route("//save//")
56 | def save_something(user_id, type_, id):
57 | user = User.query.get(user_id)
58 | if current_user.id != user_id:
59 | return {"errors": ["Invalid user."]}
60 | if type_ == "post":
61 | post = Post.query.get(id)
62 | if post in user.saved_posts:
63 | user.saved_posts.remove(post)
64 | else:
65 | user.saved_posts.append(post)
66 | elif type_ == "comment":
67 | comment = Comment.query.get(id)
68 | if comment in user.saved_comments:
69 | user.saved_comments.remove(comment)
70 | else:
71 | user.saved_comments.append(comment)
72 | db.session.commit()
73 | return FullUserResponse.from_orm(user).dict()
74 |
--------------------------------------------------------------------------------
/app/models/comment.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.schemas.user import MinimalUserResponse
5 |
6 |
7 | class Comment(db.Model):
8 | __tablename__ = "comments"
9 |
10 | id = db.Column(db.Integer, primary_key=True)
11 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
12 | thread_id = db.Column(db.Integer, db.ForeignKey("threads.id"), nullable=False)
13 | comment_id = db.Column(db.Integer, db.ForeignKey("comments.id"), nullable=True)
14 | path = db.Column(db.String(255), nullable=False)
15 | level = db.Column(db.Integer, nullable=False)
16 | body = db.Column(db.Text, nullable=False)
17 | created_at = db.Column(
18 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
19 | )
20 | updated_at = db.Column(
21 | db.DateTime, nullable=False, default=datetime.datetime.utcnow
22 | )
23 |
24 | thread = db.relationship("Thread", back_populates="comments")
25 | user = db.relationship("User", back_populates="comments")
26 | children = db.relationship(
27 | "Comment", backref=db.backref("parent", remote_side=[id])
28 | )
29 | ratings = db.relationship("CommentRating", back_populates="comment")
30 |
31 | def to_simple_dict(self):
32 | return {
33 | "id": self.id,
34 | "body": self.body,
35 | "user": MinimalUserResponse.from_orm(self.user).dict(),
36 | "comment_id": self.comment_id,
37 | }
38 |
39 | def to_search_dict(self):
40 | return {
41 | "id": self.id,
42 | "body": self.body,
43 | "user": MinimalUserResponse.from_orm(self.user).dict(),
44 | "post": self.thread.post.to_search_dict(),
45 | "ratings": {rating.user_id: rating.to_dict() for rating in self.ratings},
46 | "created_at": self.created_at,
47 | }
48 |
49 | def to_dict(self):
50 | return {
51 | "id": self.id,
52 | "user": MinimalUserResponse.from_orm(self.user).dict(),
53 | "body": self.body,
54 | "thread_id": self.thread_id,
55 | "path": self.path,
56 | "level": self.level,
57 | "created_at": self.created_at,
58 | "updated_at": self.updated_at,
59 | "children": [child.to_simple_dict() for child in self.children],
60 | "ratings": {rating.user_id: rating.to_dict() for rating in self.ratings},
61 | "post": self.thread.post.to_search_dict(),
62 | }
63 |
--------------------------------------------------------------------------------
/react-app/src/components/CommunitiesContainer/CommunitiesContainer.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { v4 as uuidv4 } from 'uuid';
4 |
5 | import {
6 | session,
7 | communities as communitiesSelectors,
8 | } from '../../store/selectors';
9 | import { getCommunities } from '../../store/communities';
10 | import Community from '../Community';
11 | import CreateCommunityForm from '../CreateCommunityForm';
12 |
13 | const CommunitiesContainer = () => {
14 | const dispatch = useDispatch();
15 | const user = useSelector(session.user());
16 | const communities = useSelector(communitiesSelectors.all());
17 |
18 | const [currentCommunities, setCurrentCommunities] = useState([]);
19 | const [currentMax, setCurrentMax] = useState(20);
20 | const [max, setMax] = useState(0);
21 | const [isLoaded, setIsLoaded] = useState(false);
22 |
23 | useEffect(() => {
24 | dispatch(getCommunities());
25 | }, [dispatch]);
26 |
27 | useEffect(() => {
28 | if (communities) {
29 | setIsLoaded(true);
30 | setMax(Object.values(communities).length);
31 | }
32 | }, [communities]);
33 |
34 | useEffect(() => {
35 | if (currentMax < max) {
36 | setCurrentCommunities(Object.values(communities).slice(0, currentMax));
37 | } else if (currentMax % max < 20) {
38 | setCurrentCommunities(Object.values(communities));
39 | } else {
40 | setCurrentCommunities((prev) =>
41 | prev.concat(Object.values(communities).slice(0, currentMax % max))
42 | );
43 | }
44 | }, [currentMax, max, communities]);
45 |
46 | useEffect(() => {
47 | const scrollListener = () => {
48 | const scroll =
49 | document.body.scrollTop || document.documentElement.scrollTop;
50 | const height =
51 | document.documentElement.scrollHeight -
52 | document.documentElement.clientHeight;
53 | const scrolled = scroll / height;
54 | if (scrolled > 0.9) {
55 | setCurrentMax((prev) => prev + 20);
56 | }
57 | };
58 |
59 | window.addEventListener('scroll', scrollListener);
60 | return () => window.removeEventListener('scroll', scrollListener);
61 | }, []);
62 |
63 | if (!isLoaded) {
64 | return null;
65 | }
66 |
67 | return (
68 | <>
69 | {user && }
70 | {currentCommunities.map((community) => (
71 |
72 | ))}
73 | >
74 | );
75 | };
76 |
77 | export default CommunitiesContainer;
78 |
--------------------------------------------------------------------------------
/api/migrations/env.py:
--------------------------------------------------------------------------------
1 | from logging.config import fileConfig
2 | from urllib import parse
3 |
4 | from alembic import context
5 | from sqlalchemy import engine_from_config, pool
6 |
7 | from api.models import Base
8 | from api.settings import settings
9 |
10 | # this is the Alembic Config object, which provides
11 | # access to the values within the .ini file in use.
12 | config = context.config
13 | config.set_main_option("sqlalchemy.url", parse.unquote(str(settings.database_url)))
14 |
15 | # Interpret the config file for Python logging.
16 | # This line sets up loggers basically.
17 | if config.config_file_name is not None:
18 | fileConfig(config.config_file_name)
19 |
20 | # add your model's MetaData object here
21 | # for 'autogenerate' support
22 | # from myapp import mymodel
23 | # target_metadata = mymodel.Base.metadata
24 | target_metadata = Base.metadata
25 |
26 | # other values from the config, defined by the needs of env.py,
27 | # can be acquired:
28 | # my_important_option = config.get_main_option("my_important_option")
29 | # ... etc.
30 |
31 |
32 | def run_migrations_offline() -> None:
33 | """Run migrations in 'offline' mode.
34 |
35 | This configures the context with just a URL
36 | and not an Engine, though an Engine is acceptable
37 | here as well. By skipping the Engine creation
38 | we don't even need a DBAPI to be available.
39 |
40 | Calls to context.execute() here emit the given string to the
41 | script output.
42 |
43 | """
44 | url = config.get_main_option("sqlalchemy.url")
45 | context.configure(
46 | url=url,
47 | target_metadata=target_metadata,
48 | literal_binds=True,
49 | dialect_opts={"paramstyle": "named"},
50 | )
51 |
52 | with context.begin_transaction():
53 | context.run_migrations()
54 |
55 |
56 | def run_migrations_online() -> None:
57 | """Run migrations in 'online' mode.
58 |
59 | In this scenario we need to create an Engine
60 | and associate a connection with the context.
61 |
62 | """
63 | connectable = engine_from_config(
64 | config.get_section(config.config_ini_section, {}),
65 | prefix="sqlalchemy.",
66 | poolclass=pool.NullPool,
67 | )
68 |
69 | with connectable.connect() as connection:
70 | context.configure(connection=connection, target_metadata=target_metadata)
71 |
72 | with context.begin_transaction():
73 | context.run_migrations()
74 |
75 |
76 | if context.is_offline_mode():
77 | run_migrations_offline()
78 | else:
79 | run_migrations_online()
80 |
--------------------------------------------------------------------------------
/app/routes/api/auth.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_login import current_user, login_user, logout_user
3 | from sqlalchemy import or_
4 |
5 | from app.forms import LoginForm, SignUpForm
6 | from app.helpers import validation_errors_to_error_messages
7 | from app.models import User, db
8 | from app.schemas.responses import LogoutResponse, UnauthenticatedErrorsResponse
9 | from app.schemas.user import FullUserResponse
10 |
11 | auth = Blueprint("auth", __name__)
12 |
13 |
14 | @auth.route("")
15 | def authenticate():
16 | """
17 | Authenticates a user.
18 | """
19 | if current_user.is_authenticated:
20 | return FullUserResponse.from_orm(current_user).dict()
21 | response = UnauthenticatedErrorsResponse(errors=["Unauthorized"]).dict()
22 | return response, response["status"]
23 |
24 |
25 | @auth.route("/login", methods=["POST"])
26 | def login():
27 | """
28 | Logs a user in
29 | """
30 | form = LoginForm()
31 | if form.validate_on_submit():
32 | credential = form.data["credential"]
33 | user = User.query.filter(
34 | or_(User.email == credential, User.username == credential)
35 | ).first()
36 | login_user(user)
37 | return FullUserResponse.model_validate(user).dict()
38 | response = UnauthenticatedErrorsResponse(
39 | errors=validation_errors_to_error_messages(form.errors)
40 | ).dict()
41 | return response, response["status"]
42 |
43 |
44 | @auth.route("/logout")
45 | def logout():
46 | """
47 | Logs a user out
48 | """
49 | logout_user()
50 | return LogoutResponse().dict()
51 |
52 |
53 | @auth.route("/signup", methods=["POST"])
54 | def sign_up():
55 | """
56 | Creates a new user and logs them in
57 | """
58 | form = SignUpForm()
59 | if form.validate_on_submit():
60 | user = User(
61 | username=form.data["username"],
62 | email=form.data["email"],
63 | password=form.data["password"],
64 | )
65 | db.session.add(user)
66 | db.session.commit()
67 | login_user(user)
68 | return FullUserResponse.from_orm(user).dict()
69 | response = UnauthenticatedErrorsResponse(
70 | errors=validation_errors_to_error_messages(form.errors)
71 | ).dict()
72 | return response, response["status"]
73 |
74 |
75 | @auth.route("/unauthorized")
76 | def unauthorized():
77 | """
78 | Returns unauthorized JSON when flask-login authentication fails
79 | """
80 | response = UnauthenticatedErrorsResponse().dict()
81 | return response, response["status"]
82 |
--------------------------------------------------------------------------------
/react-app/src/components/MeetupsContainer/MeetupsContainer.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { v4 as uuidv4 } from 'uuid';
4 |
5 | import { session, meetups as meetupsSelectors } from '../../store/selectors';
6 | import { getMeetups, getMaxNumberOfMeetups } from '../../store/meetups';
7 | import Meetup from '../Meetup';
8 | import CreateMeetup from '../CreateMeetup';
9 |
10 | const MeetupsContainer = () => {
11 | const dispatch = useDispatch();
12 | const meetups = useSelector(meetupsSelectors.all());
13 | const maxMeetups = useSelector(meetupsSelectors.max());
14 | const user = useSelector(session.user());
15 |
16 | const [page, setPage] = useState(1);
17 | const [currentMeetups, setCurrentMeetups] = useState([]);
18 | const [isLoaded, setIsLoaded] = useState(false);
19 |
20 | useEffect(() => {
21 | dispatch(getMaxNumberOfMeetups());
22 | }, [dispatch]);
23 |
24 | useEffect(() => {
25 | dispatch(getMeetups(1));
26 | }, [dispatch]);
27 |
28 | useEffect(() => {
29 | if (page * 20 - maxMeetups < 20) {
30 | dispatch(getMeetups(page));
31 | }
32 | }, [dispatch, page, maxMeetups]);
33 |
34 | useEffect(() => {
35 | if (meetups) {
36 | setIsLoaded(true);
37 | setCurrentMeetups(Object.values(meetups));
38 | }
39 | }, [meetups]);
40 |
41 | useEffect(() => {
42 | if (page * 20 > maxMeetups) {
43 | setCurrentMeetups((prev) =>
44 | prev.concat(
45 | Object.values(meetups).slice(
46 | 0,
47 | (page * 20) % maxMeetups || maxMeetups
48 | )
49 | )
50 | );
51 | }
52 | }, [meetups, maxMeetups, page]);
53 |
54 | useEffect(() => {
55 | const scrollListener = () => {
56 | const scroll =
57 | document.body.scrollTop || document.documentElement.scrollTop;
58 | const height =
59 | document.documentElement.scrollHeight -
60 | document.documentElement.clientHeight;
61 | const scrolled = scroll / height;
62 | if (scrolled > 0.9) {
63 | setPage((prev) => prev + 1);
64 | }
65 | };
66 |
67 | window.addEventListener('scroll', scrollListener);
68 | return () => window.removeEventListener('scroll', scrollListener);
69 | }, [page, maxMeetups, meetups]);
70 |
71 | if (!isLoaded) {
72 | return null;
73 | }
74 |
75 | return (
76 | <>
77 | {user && }
78 | {currentMeetups.map((meetup) => (
79 |
80 | ))}
81 | >
82 | );
83 | };
84 |
85 | export default MeetupsContainer;
86 |
--------------------------------------------------------------------------------
/react-app/src/store/communities.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_COMMUNITIES,
3 | SET_COMMUNITY,
4 | SET_SIDEBAR_COMMUNITY,
5 | SET_SIDEBAR_COMMUNITIES,
6 | } from './constants';
7 | import { setCommunities, setCommunity } from './actions';
8 | import api from './api';
9 |
10 | export const getCommunities = () => async (dispatch) => {
11 | const res = await api(`/api/communities`);
12 | const communities = await res.json();
13 | dispatch(setCommunities(communities));
14 | return communities;
15 | };
16 |
17 | export const getCommunity = (communityId) => async (dispatch) => {
18 | const res = await api(`/api/communities/${communityId}`);
19 | const community = await res.json();
20 | dispatch(setCommunity(community));
21 | return community;
22 | };
23 |
24 | export const createCommunity = (community) => async (dispatch) => {
25 | const res = await api('/api/communities/', {
26 | method: 'POST',
27 | headers: {
28 | 'Content-Type': 'application/json',
29 | },
30 | body: JSON.stringify(community),
31 | });
32 | const newCommunity = await res.json();
33 | if (!newCommunity.errors) {
34 | dispatch(setCommunity(newCommunity));
35 | }
36 | return newCommunity;
37 | };
38 |
39 | export const updateCommunity = (community) => async (dispatch) => {
40 | const res = await api(`/api/communities/${community.id}`, {
41 | method: 'PUT',
42 | headers: {
43 | 'Content-Type': 'application/json',
44 | },
45 | body: JSON.stringify(community),
46 | });
47 | const updatedCommunity = await res.json();
48 | if (!updatedCommunity.errors) {
49 | dispatch(setCommunity(updatedCommunity));
50 | }
51 | return updatedCommunity;
52 | };
53 |
54 | export const deleteCommunity = (communityId) => async (dispatch) => {
55 | const res = await api(`/api/communities/${communityId}`, {
56 | method: 'DELETE',
57 | });
58 | const communities = await res.json();
59 | if (!communities.errors) {
60 | dispatch(setCommunities(communities));
61 | }
62 | return communities;
63 | };
64 |
65 | const initialState = {};
66 |
67 | const communityReducer = (state = initialState, action) => {
68 | switch (action.type) {
69 | case SET_SIDEBAR_COMMUNITIES:
70 | case SET_COMMUNITIES:
71 | return {
72 | ...state,
73 | ...Object.fromEntries(
74 | action.communities.map((community) => [community.id, community])
75 | ),
76 | };
77 | case SET_SIDEBAR_COMMUNITY:
78 | case SET_COMMUNITY:
79 | return { ...state, [action.community.id]: action.community };
80 | default:
81 | return state;
82 | }
83 | };
84 |
85 | export default communityReducer;
86 |
--------------------------------------------------------------------------------
/app/models/post.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from app.models.db import db
4 | from app.models.posts_tag import posts_tags
5 | from app.schemas.user import MinimalUserResponse
6 |
7 |
8 | class Post(db.Model):
9 | __tablename__ = "posts"
10 |
11 | id = db.Column(db.Integer, primary_key=True)
12 | user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
13 | community_id = db.Column(
14 | db.Integer, db.ForeignKey("communities.id"), nullable=False
15 | )
16 | title = db.Column(db.String(100), nullable=False)
17 | body = db.Column(db.Text)
18 | created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
19 |
20 | user = db.relationship("User", back_populates="posts")
21 | community = db.relationship("Community", back_populates="posts")
22 | images = db.relationship("PostsImage", back_populates="post")
23 | tags = db.relationship("Tag", secondary=posts_tags)
24 | threads = db.relationship("Thread", back_populates="post")
25 | ratings = db.relationship("PostRating", back_populates="post")
26 |
27 | def to_simple_dict(self):
28 | return {
29 | "id": self.id,
30 | "title": self.title,
31 | "body": self.body,
32 | "images": [image.image_url for image in self.images],
33 | "community": self.community.to_simple_dict(),
34 | "tags": [tag.name for tag in self.tags],
35 | "user": MinimalUserResponse.from_orm(self.user).dict(),
36 | "created_at": self.created_at,
37 | }
38 |
39 | def to_search_dict(self):
40 | return {
41 | "id": self.id,
42 | "title": self.title,
43 | "body": self.body,
44 | "images": [image.image_url for image in self.images],
45 | "community": self.community.name,
46 | "ratings": {
47 | rating.user_id: rating.to_simple_dict() for rating in self.ratings
48 | },
49 | }
50 |
51 | def to_dict(self):
52 | return {
53 | "id": self.id,
54 | "user": MinimalUserResponse.from_orm(self.user).dict(),
55 | "community": self.community.to_simple_dict(),
56 | "title": self.title,
57 | "body": self.body,
58 | "images": [image.image_url for image in self.images],
59 | "tags": [tag.name for tag in self.tags],
60 | "threads": {thread.id: thread.to_dict() for thread in self.threads},
61 | "created_at": self.created_at,
62 | "ratings": {
63 | rating.user_id: rating.to_simple_dict() for rating in self.ratings
64 | },
65 | }
66 |
--------------------------------------------------------------------------------
/app/routes/api/meetup.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from flask import Blueprint, request
3 |
4 | from app.config import Config
5 | from app.forms import CreateMeetup
6 | from app.helpers import validation_errors_to_error_messages
7 | from app.models import Meetup, db
8 |
9 | meetup = Blueprint("meetups", __name__)
10 |
11 |
12 | @meetup.route("")
13 | def get_meetups():
14 | page = int(request.args.get("page", 0))
15 | meetups = Meetup.query.paginate(page=page, per_page=20)
16 | return {meetup.id: meetup.to_dict() for meetup in meetups.items}
17 |
18 |
19 | @meetup.route("/max")
20 | def get_max_number_of_meetups():
21 | number = Meetup.query.count()
22 | return {"max": number}
23 |
24 |
25 | @meetup.route("", methods=["POST"])
26 | def create_meetup():
27 | form = CreateMeetup()
28 | if form.validate_on_submit():
29 | meetup = Meetup()
30 | print(request.form.get("date"))
31 | form.populate_obj(meetup)
32 | db.session.add(meetup)
33 | db.session.commit()
34 | return meetup.to_dict()
35 | return {"errors": validation_errors_to_error_messages(form.errors)}
36 |
37 |
38 | @meetup.route("/")
39 | def get_meetup_by_id(meetup_id):
40 | meetup = Meetup.query.get(meetup_id)
41 | return meetup.to_dict()
42 |
43 |
44 | @meetup.route("/", methods=["PUT", "DELETE"])
45 | def update_meetup(meetup_id):
46 | meetup = Meetup.query.get(meetup_id)
47 | if request.method == "PUT":
48 | form = CreateMeetup()
49 | if form.validate_on_submit():
50 | meetup.name = form["name"].data
51 | meetup.description = form["description"].data
52 | meetup.city = form["city"].data
53 | meetup.state = form["state"].data
54 | meetup.date = form["date"].data
55 | db.session.commit()
56 | return meetup.to_dict()
57 | return {"errors": validation_errors_to_error_messages(form.errors)}
58 | elif request.method == "DELETE":
59 | if meetup:
60 | db.session.delete(meetup)
61 | db.session.commit()
62 | return {"message": "Delete Successful"}
63 | return {"errors": "Invalid Meetup."}
64 | return "Bad route", 404
65 |
66 |
67 | @meetup.route("//location")
68 | def get_meetup_lat_lng(meetup_id):
69 | meetup = Meetup.query.get(meetup_id)
70 | if meetup:
71 | response = requests.get(
72 | "https://api.opencagedata.com/geocode/v1/json?"
73 | + f"key={Config.OPEN_CAGE_API_KEY}"
74 | + f"&q={meetup.city},{meetup.state},USA"
75 | )
76 | data = response.json()
77 | meetup.lng = data["results"][0]["geometry"]["lng"]
78 | meetup.lat = data["results"][0]["geometry"]["lat"]
79 | db.session.commit()
80 | return meetup.to_dict()
81 | return {"errors": "Invalid meetup ID."}
82 |
--------------------------------------------------------------------------------
/STARTER-README.md:
--------------------------------------------------------------------------------
1 | # Flask React Project
2 |
3 | This is the backend for the Flask React project.
4 |
5 | ## Getting started
6 |
7 | 1. Clone this repository (only this branch)
8 |
9 | ```bash
10 | git clone https://github.com/appacademy-starters/python-project-starter.git
11 | ```
12 |
13 | 2. Install dependencies
14 |
15 | ```bash
16 | pipenv install --dev -r dev-requirements.txt && pipenv install -r requirements.txt
17 | ```
18 |
19 | 3. Create a **.env** file based on the example with proper settings for your
20 | development environment
21 | 4. Setup your PostgreSQL user, password and database and make sure it matches your **.env** file
22 |
23 | 5. Get into your pipenv, migrate your database, seed your database, and run your flask app
24 |
25 | ```bash
26 | pipenv shell
27 | ```
28 |
29 | ```bash
30 | flask db upgrade
31 | ```
32 |
33 | ```bash
34 | flask seed all
35 | ```
36 |
37 | ```bash
38 | flask run
39 | ```
40 |
41 | 6. To run the React App in development, checkout the [README](./react-app/README.md) inside the `react-app` directory.
42 |
43 | ---
44 |
45 | _IMPORTANT!_
46 | If you add any python dependencies to your pipfiles, you'll need to regenerate your requirements.txt before deployment.
47 | You can do this by running:
48 |
49 | ```bash
50 | pipenv lock -r > requirements.txt
51 | ```
52 |
53 | _ALSO IMPORTANT!_
54 | psycopg2-binary MUST remain a dev dependency because you can't install it on apline-linux.
55 | There is a layer in the Dockerfile that will install psycopg2 (not binary) for us.
56 |
57 | ---
58 |
59 | ## Deploy to Heroku
60 |
61 | 1. Create a new project on Heroku
62 | 2. Under Resources click "Find more add-ons" and add the add on called "Heroku Postgres"
63 | 3. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-command-line)
64 | 4. Run
65 |
66 | ```bash
67 | heroku login
68 | ```
69 |
70 | 5. Login to the heroku container registry
71 |
72 | ```bash
73 | heroku container:login
74 | ```
75 |
76 | 6. Update the `REACT_APP_BASE_URL` variable in the Dockerfile.
77 | This should be the full URL of your Heroku app: i.e. "https://flask-react-aa.herokuapp.com"
78 | 7. Push your docker container to heroku from the root directory of your project.
79 | This will build the dockerfile and push the image to your heroku container registry
80 |
81 | ```bash
82 | heroku container:push web -a {NAME_OF_HEROKU_APP}
83 | ```
84 |
85 | 8. Release your docker container to heroku
86 |
87 | ```bash
88 | heroku container:release web -a {NAME_OF_HEROKU_APP}
89 | ```
90 |
91 | 9. set up your database:
92 |
93 | ```bash
94 | heroku run -a {NAME_OF_HEROKU_APP} flask db upgrade
95 | heroku run -a {NAME_OF_HEROKU_APP} flask seed all
96 | ```
97 |
98 | 10. Under Settings find "Config Vars" and add any additional/secret .env variables.
99 |
100 | 11. profit
101 |
--------------------------------------------------------------------------------