diff --git a/Planteer/Planteer/Planteer/__init__.py b/Planteer/Planteer/Planteer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/Planteer/asgi.py b/Planteer/Planteer/Planteer/asgi.py new file mode 100644 index 0000000..7693f2d --- /dev/null +++ b/Planteer/Planteer/Planteer/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for Planteer project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Planteer.settings') + +application = get_asgi_application() diff --git a/Planteer/Planteer/Planteer/settings.py b/Planteer/Planteer/Planteer/settings.py new file mode 100644 index 0000000..572f6d3 --- /dev/null +++ b/Planteer/Planteer/Planteer/settings.py @@ -0,0 +1,134 @@ +""" +Django settings for Planteer project. + +Generated by 'django-admin startproject' using Django 6.0.3. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +from pathlib import Path +import os +from dotenv import load_dotenv + +load_dotenv() +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / '.env') + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-b9k6@q&&7d!-0s!mpsz62&srkmvgj4t0th_lohrpu&zj+cc$^p' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'main', + 'plants', + 'contact', + 'accounts', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'Planteer.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'Planteer.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'Asia/Riyadh' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True + +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') \ No newline at end of file diff --git a/Planteer/Planteer/Planteer/urls.py b/Planteer/Planteer/Planteer/urls.py new file mode 100644 index 0000000..b0ed4f1 --- /dev/null +++ b/Planteer/Planteer/Planteer/urls.py @@ -0,0 +1,87 @@ +""" +URL configuration for Planteer project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.contrib.auth import views as auth_views + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('main.urls')), + path('plants/', include('plants.urls')), + path('contact/', include('contact.urls')), + path("accounts/", include("accounts.urls")), + + path('reset/',auth_views.PasswordResetView.as_view(template_name='accounts/password_reset.html',html_email_template_name='accounts/password_reset_email.html'),name='password_reset'), + path('reset/done/', auth_views.PasswordResetDoneView.as_view(template_name='accounts/password_reset_done.html'), name='password_reset_done'), + path('reset///', auth_views.PasswordResetConfirmView.as_view(template_name='accounts/password_reset_confirm.html'), name='password_reset_confirm'), + path('reset/complete/', auth_views.PasswordResetCompleteView.as_view(template_name='accounts/password_reset_complete.html'), name='password_reset_complete'), +] + + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + +""" +URL configuration for Planteer project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.contrib.auth import views as auth_views + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('main.urls')), + path('plants/', include('plants.urls')), + path('contact/', include('contact.urls')), + path("accounts/", include("accounts.urls")), + + path( + 'reset/', + auth_views.PasswordResetView.as_view( + template_name='accounts/password_reset.html', + html_email_template_name='accounts/password_reset_email.html' + ), + name='password_reset' + ), + + path('reset/done/', auth_views.PasswordResetDoneView.as_view(template_name='accounts/password_reset_done.html'), name='password_reset_done'), + path('reset///', auth_views.PasswordResetConfirmView.as_view(template_name='accounts/password_reset_confirm.html'), name='password_reset_confirm'), + path('reset/complete/', auth_views.PasswordResetCompleteView.as_view(template_name='accounts/password_reset_complete.html'), name='password_reset_complete'), +] + + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + diff --git a/Planteer/Planteer/Planteer/wsgi.py b/Planteer/Planteer/Planteer/wsgi.py new file mode 100644 index 0000000..f14ac6f --- /dev/null +++ b/Planteer/Planteer/Planteer/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for Planteer project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Planteer.settings') + +application = get_wsgi_application() diff --git a/Planteer/Planteer/accounts/__init__.py b/Planteer/Planteer/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/accounts/admin.py b/Planteer/Planteer/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/Planteer/Planteer/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/Planteer/Planteer/accounts/apps.py b/Planteer/Planteer/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/Planteer/Planteer/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/Planteer/Planteer/accounts/forms.py b/Planteer/Planteer/accounts/forms.py new file mode 100644 index 0000000..f0c9616 --- /dev/null +++ b/Planteer/Planteer/accounts/forms.py @@ -0,0 +1,88 @@ +from django import forms +from django.contrib.auth.models import User +import re + +class SignUpForm(forms.ModelForm): + password = forms.CharField(widget=forms.PasswordInput) + avatar = forms.ImageField(required=False) + about = forms.CharField(widget=forms.Textarea, required=False) + + class Meta: + model = User + fields = ["email", "username", "first_name", "last_name", "password"] + + + def clean_email(self): + email = self.cleaned_data["email"] + + if User.objects.filter(email=email).exists(): + raise forms.ValidationError("Email already exists.") + + return email + + + def clean_username(self): + username = self.cleaned_data["username"] + + if len(username) < 8: + raise forms.ValidationError("Username must be at least 8 characters.") + + if not re.match(r'^[A-Za-z0-9_]+$', username): + raise forms.ValidationError("Only letters, numbers, underscore allowed.") + + if User.objects.filter(username=username).exists(): + raise forms.ValidationError("Username already exists.") + + return username + + + def clean(self): + cleaned_data = super().clean() + + first_name = cleaned_data.get("first_name") + last_name = cleaned_data.get("last_name") + + if first_name and not first_name.isalpha(): + self.add_error("first_name", "Only letters allowed.") + + if last_name and not last_name.isalpha(): + self.add_error("last_name", "Only letters allowed.") + + return cleaned_data + + + def clean_about(self): + about = self.cleaned_data.get("about") + + if about and len(about) < 5: + raise forms.ValidationError("About must be at least 5 characters.") + + if about and len(about) > 200: + raise forms.ValidationError("About must be less than 200 characters.") + + return about + + + def clean_avatar(self): + avatar = self.cleaned_data.get("avatar") + + if avatar and not avatar.content_type.startswith("image/"): + raise forms.ValidationError("Avatar must be an image file.") + + return avatar + + + def clean_password(self): + password = self.cleaned_data["password"] + + if len(password) < 8: + raise forms.ValidationError("Password must be at least 8 characters.") + + if not any(c.isdigit() for c in password): + raise forms.ValidationError("Password must contain a number.") + + if not any(c.isalpha() for c in password): + raise forms.ValidationError("Password must contain a letter.") + + return password + diff --git a/Planteer/Planteer/accounts/migrations/0001_initial.py b/Planteer/Planteer/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..68d5e02 --- /dev/null +++ b/Planteer/Planteer/accounts/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-27 06:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('about', models.TextField(blank=True)), + ('avatar', models.ImageField(default='images/avatars/avatar.png', upload_to='images/avatars/')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/Planteer/Planteer/accounts/migrations/0002_bookmark.py b/Planteer/Planteer/accounts/migrations/0002_bookmark.py new file mode 100644 index 0000000..0cd9a7b --- /dev/null +++ b/Planteer/Planteer/accounts/migrations/0002_bookmark.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.3 on 2026-04-27 22:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ('plants', '0016_merge_20260427_0940'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Bookmark', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.plant')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'plant')}, + }, + ), + ] diff --git a/Planteer/Planteer/accounts/migrations/0003_alter_bookmark_user.py b/Planteer/Planteer/accounts/migrations/0003_alter_bookmark_user.py new file mode 100644 index 0000000..71b583f --- /dev/null +++ b/Planteer/Planteer/accounts/migrations/0003_alter_bookmark_user.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.3 on 2026-04-27 23:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_bookmark'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='bookmark', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/Planteer/Planteer/accounts/migrations/__init__.py b/Planteer/Planteer/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/accounts/models.py b/Planteer/Planteer/accounts/models.py new file mode 100644 index 0000000..ce01108 --- /dev/null +++ b/Planteer/Planteer/accounts/models.py @@ -0,0 +1,24 @@ +from django.db import models +from django.contrib.auth.models import User +from plants.models import Plant +# Create your models here. + +class Profile(models.Model): + user=models.OneToOneField(User,on_delete=models.CASCADE) + about=models.TextField(blank=True) + avatar=models.ImageField(upload_to="images/avatars/",default="images/avatars/avatar.png") + + def __str__(self): + return f"Profile {self.user.username}" + + +class Bookmark(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + plant = models.ForeignKey(Plant, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'plant') + + def __str__(self): + return f"{self.user.username} - {self.plant.name}" diff --git a/Planteer/Planteer/accounts/templates/accounts/password_reset.html b/Planteer/Planteer/accounts/templates/accounts/password_reset.html new file mode 100644 index 0000000..9d2b81d --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/password_reset.html @@ -0,0 +1,24 @@ +{% extends 'main/base.html' %} + +{% block title %}Reset Password{% endblock %} + +{% block content %} +
+
+ Password Help +

Reset Password

+

Enter your email and we will send you a reset link.

+ +
+ {% csrf_token %} + +
+ + +
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/password_reset_complete.html b/Planteer/Planteer/accounts/templates/accounts/password_reset_complete.html new file mode 100644 index 0000000..e2f82cc --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/password_reset_complete.html @@ -0,0 +1,15 @@ +{% extends 'main/base.html' %} + +{% block title %}Password Changed{% endblock %} + +{% block content %} +
+
+ Success +

Password Changed

+

Your password has been changed successfully.

+ + Sign In +
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/password_reset_confirm.html b/Planteer/Planteer/accounts/templates/accounts/password_reset_confirm.html new file mode 100644 index 0000000..6383493 --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/password_reset_confirm.html @@ -0,0 +1,35 @@ +{% extends 'main/base.html' %} + +{% block title %}New Password{% endblock %} + +{% block content %} +
+
+ New Password +

Set New Password

+

Enter your new password below.

+ +
+ {% csrf_token %} + +
+ + {{ form.new_password1 }} + {% for error in form.new_password1.errors %} + {{ error }} + {% endfor %} +
+ +
+ + {{ form.new_password2 }} + {% for error in form.new_password2.errors %} + {{ error }} + {% endfor %} +
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/password_reset_done.html b/Planteer/Planteer/accounts/templates/accounts/password_reset_done.html new file mode 100644 index 0000000..f4e043d --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/password_reset_done.html @@ -0,0 +1,15 @@ +{% extends 'main/base.html' %} + +{% block title %}Email Sent{% endblock %} + +{% block content %} +
+
+ Check Email +

Email Sent

+

If an account exists with that email, a password reset link has been sent.

+ + Back to Sign In +
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/password_reset_email.html b/Planteer/Planteer/accounts/templates/accounts/password_reset_email.html new file mode 100644 index 0000000..d38671f --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/password_reset_email.html @@ -0,0 +1,42 @@ + + + + + + + + + +
+ +

🌿 Planteer

+ +

Hello {{ user.username }},

+ +

+ You requested to reset your password.
+ Click the button below to continue. +

+ + + Reset Password + + +

+ If the button doesn't work, copy this link: +

+ +

+ {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} +

+ +

+ If you didn’t request this, ignore this email. +

+ +
+ + + + \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/profile.html b/Planteer/Planteer/accounts/templates/accounts/profile.html new file mode 100644 index 0000000..31ad4bf --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/profile.html @@ -0,0 +1,144 @@ +{% extends 'main/base.html' %} +{% load static %} +{% block title %}Profile for {{ user.username }}{% endblock %} + +{% block content %} + +
+
+ +
+ +
+
+ {% if user.profile.avatar %} + {{ user.username }} + {% else %} + {{ user.username }} + {% endif %} +
+ +

@{{ user.username }}

+ + {% if request.user == user %} + + Edit Profile + + {% endif %} +
+ +
+ +
+ {% if user.first_name or user.last_name %} +

{{ user.first_name }} {{ user.last_name }}

+ {% else %} +

{{ user.username }}

+ {% endif %} + +

{{ user.email }}

+
+ +
+
+ {{ comments_count }} + Comments +
+ +
+ {{ bookmarks_count }} + Bookmarks +
+ +
+ {{ user.date_joined|date:"M d, Y" }} + Joined +
+
+ +
+ +
+

About

+ + {% if user.profile.about %} +

{{ user.profile.about }}

+ {% else %} +

No about information yet.

+ {% endif %} +
+ +
+ +
+

+ {{ user.first_name|default:user.username }} Comments +

+ + {% if comments %} +
+ {% for comment in comments %} +
+

+ {% if comment.user.first_name or comment.user.last_name %} + {{ comment.user.first_name }} {{ comment.user.last_name }} + {% else %} + {{ comment.user.username }} + {% endif %} +

+ +

{{ comment.comment }}

+ +

+ On Plant: + + {{ comment.plant.name }} + +

+ +

+ {{ comment.created_at|date:"M. d, Y, h:i a" }} +

+
+ {% endfor %} +
+ {% else %} +

No comments yet

+ {% endif %} +
+ + {% if request.user == user %} +
+ +
+

Bookmarks

+ +
+ {% for bookmark in request.user.bookmark_set.all %} + + {% empty %} +

No bookmarks yet

+ {% endfor %} +
+
+ {% endif %} + +
+ +
+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/signin.html b/Planteer/Planteer/accounts/templates/accounts/signin.html new file mode 100644 index 0000000..42e5e09 --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/signin.html @@ -0,0 +1,46 @@ +{% extends 'main/base.html' %} + +{% block title %}Sign In{% endblock %} + +{% block content %} +
+
+ Welcome Back +

Sign In

+

Login to manage your Planteer account.

+ +
+ {% csrf_token %} + + {% if errors.login %} +
+ {{ errors.login }} +
+ {% endif %} + +
+ + +
+ + +
+ + +
+ + + +
+ +
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/signup.html b/Planteer/Planteer/accounts/templates/accounts/signup.html new file mode 100644 index 0000000..7f681b3 --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/signup.html @@ -0,0 +1,83 @@ +{% extends 'main/base.html' %} + +{% block title %}Sign Up new user{% endblock %} + +{% block content %} +
+ +
+ + Join Planteer +

Sign Up

+

Create an account and start your green journey.

+ +
+ {% csrf_token %} + +
+ + + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+ +
+ + + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+ +
+
+ + + {% for error in form.first_name.errors %} + {{ error }} + {% endfor %} +
+ +
+ + + {% for error in form.last_name.errors %} + {{ error }} + {% endfor %} +
+
+ +
+ + + {% for error in form.about.errors %} + {{ error }} + {% endfor %} +
+ +
+ + + {% for error in form.avatar.errors %} + {{ error }} + {% endfor %} +
+ +
+ + + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ + +
+ +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/templates/accounts/update_profile.html b/Planteer/Planteer/accounts/templates/accounts/update_profile.html new file mode 100644 index 0000000..436e856 --- /dev/null +++ b/Planteer/Planteer/accounts/templates/accounts/update_profile.html @@ -0,0 +1,58 @@ +{% extends 'main/base.html'%} +{% load static %} + +{% block title %}Update {{ request.user.username }}{% endblock %} + +{% block content %} + +
+ +
+ +

Update Profile

+ +
+ + {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
Current Avatar
+ + {% if request.user.profile.avatar %} + + {% else %} + + {% endif %} + + +
+ + + +
+ +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/accounts/tests.py b/Planteer/Planteer/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Planteer/Planteer/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Planteer/Planteer/accounts/urls.py b/Planteer/Planteer/accounts/urls.py new file mode 100644 index 0000000..e8ee2d6 --- /dev/null +++ b/Planteer/Planteer/accounts/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views +from django.contrib.auth import views as auth_views + +app_name = "accounts" + +urlpatterns = [ + path('signup/', views.sign_up, name="sign_up"), + path('signin/', views.sign_in, name="sign_in"), + path('logout/', views.log_out, name="log_out"), + path('profile/update/', views.update_user_profile, name="update_user_profile"), + path('profile//', views.user_profile_view, name="user_profile_view"), +] + diff --git a/Planteer/Planteer/accounts/views.py b/Planteer/Planteer/accounts/views.py new file mode 100644 index 0000000..a610783 --- /dev/null +++ b/Planteer/Planteer/accounts/views.py @@ -0,0 +1,121 @@ +from django.shortcuts import render, redirect,get_object_or_404 +from django.http import HttpRequest +from django.contrib.auth.models import User +from django.contrib.auth import authenticate, login, logout +from django.contrib import messages +from django.db import transaction + +from plants.models import Comment +from .models import Profile +from .forms import SignUpForm + +# Create your views here. + +def sign_up(request: HttpRequest): + if request.method == "POST": + form = SignUpForm(request.POST, request.FILES) + if form.is_valid(): + with transaction.atomic(): + user = form.save(commit=False) + user.set_password(form.cleaned_data["password"]) + user.save() + + avatar = form.cleaned_data.get("avatar") or "images/avatars/avatar.png" + about = form.cleaned_data.get("about", "") + + Profile.objects.create( + user=user, + about=about, + avatar=avatar + ) + + messages.success(request, "Registered successfully") + return redirect("accounts:sign_in") + + else: + form = SignUpForm() + + return render(request, "accounts/signup.html", {"form": form}) + + +def sign_in(request: HttpRequest): + errors = {} + username = "" + + if request.method == "POST": + username = request.POST.get("username", "").strip() + password = request.POST.get("password", "").strip() + + if not username or not password: + errors["login"] = "Please fill in all fields." + else: + user = authenticate(request, username=username, password=password) + + if user: + login(request, user) + return redirect(request.GET.get("next", "/")) + + errors["login"] = "Invalid username or password" + + return render(request, "accounts/signin.html", { + "errors": errors, + "username": username + }) + + +def log_out(request: HttpRequest): + logout(request) + messages.success(request, "logged out successfully") + return redirect("main:home") + + +def user_profile_view(request: HttpRequest, user_name): + user = get_object_or_404(User, username=user_name) + + Profile.objects.get_or_create(user=user) + + comments = Comment.objects.filter(user=user).order_by('-created_at') + + comments_count = comments.count() + bookmarks_count = user.bookmark_set.count() + + return render(request, 'accounts/profile.html', { + "user": user, + "comments": comments, + "comments_count": comments_count, + "bookmarks_count": bookmarks_count, + }) + + +def update_user_profile(request: HttpRequest): + if not request.user.is_authenticated: + return redirect("accounts:sign_in") + + if request.method == "POST": + try: + with transaction.atomic(): + user = request.user + + user.first_name = request.POST["first_name"] + user.last_name = request.POST["last_name"] + user.email = request.POST["email"] + user.save() + + profile = user.profile + profile.about = request.POST.get("about", "") + + if "avatar" in request.FILES: + profile.avatar = request.FILES["avatar"] + + profile.save() + messages.success(request, "updated profile successfully") + except Exception as e: + messages.error(request, "Couldn't update profile") + print(e) + + return redirect("accounts:user_profile_view", user_name=user.username) + + return render(request, "accounts/update_profile.html") + + + diff --git a/Planteer/Planteer/contact/__init__.py b/Planteer/Planteer/contact/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/contact/admin.py b/Planteer/Planteer/contact/admin.py new file mode 100644 index 0000000..811a50c --- /dev/null +++ b/Planteer/Planteer/contact/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import Contact + +admin.site.register(Contact) diff --git a/Planteer/Planteer/contact/apps.py b/Planteer/Planteer/contact/apps.py new file mode 100644 index 0000000..1f23179 --- /dev/null +++ b/Planteer/Planteer/contact/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ContactConfig(AppConfig): + name = 'contact' diff --git a/Planteer/Planteer/contact/forms.py b/Planteer/Planteer/contact/forms.py new file mode 100644 index 0000000..531d32c --- /dev/null +++ b/Planteer/Planteer/contact/forms.py @@ -0,0 +1,52 @@ +from django import forms +from .models import Contact + + +class ContactForm(forms.ModelForm): + class Meta: + model = Contact + fields = ['first_name', 'last_name', 'email', 'message'] + widgets = { + 'first_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'First name', + 'minlength': '2', + 'required': True + }), + 'last_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Last name', + 'minlength': '2', + 'required': True + }), + 'email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Email address', + 'required': True + }), + 'message': forms.Textarea(attrs={ + 'class': 'form-control', + 'placeholder': 'Your message', + 'rows': 6, + 'minlength': '10', + 'required': True + }), + } + + def clean_first_name(self): + first_name = self.cleaned_data['first_name'].strip() + if len(first_name) < 2: + raise forms.ValidationError("First name must be at least 2 characters.") + return first_name + + def clean_last_name(self): + last_name = self.cleaned_data['last_name'].strip() + if len(last_name) < 2: + raise forms.ValidationError("Last name must be at least 2 characters.") + return last_name + + def clean_message(self): + message = self.cleaned_data['message'].strip() + if len(message) < 10: + raise forms.ValidationError("Message must be at least 10 characters.") + return message \ No newline at end of file diff --git a/Planteer/Planteer/contact/migrations/0001_initial.py b/Planteer/Planteer/contact/migrations/0001_initial.py new file mode 100644 index 0000000..5357af0 --- /dev/null +++ b/Planteer/Planteer/contact/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-18 15:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=50, validators=[django.core.validators.MinLengthValidator(2)])), + ('last_name', models.CharField(max_length=50, validators=[django.core.validators.MinLengthValidator(2)])), + ('email', models.EmailField(max_length=254)), + ('message', models.TextField(validators=[django.core.validators.MinLengthValidator(10)])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/Planteer/Planteer/contact/migrations/__init__.py b/Planteer/Planteer/contact/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/contact/models.py b/Planteer/Planteer/contact/models.py new file mode 100644 index 0000000..2f1a8a9 --- /dev/null +++ b/Planteer/Planteer/contact/models.py @@ -0,0 +1,13 @@ +from django.db import models +from django.core.validators import MinLengthValidator + + +class Contact(models.Model): + first_name = models.CharField(max_length=50, validators=[MinLengthValidator(2)]) + last_name = models.CharField(max_length=50, validators=[MinLengthValidator(2)]) + email = models.EmailField() + message = models.TextField(validators=[MinLengthValidator(10)]) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.first_name} {self.last_name}" \ No newline at end of file diff --git a/Planteer/Planteer/contact/templates/contact/contact.html b/Planteer/Planteer/contact/templates/contact/contact.html new file mode 100644 index 0000000..557202e --- /dev/null +++ b/Planteer/Planteer/contact/templates/contact/contact.html @@ -0,0 +1,21 @@ +{% extends 'main/base.html' %} +{% block title %}Contact Us | Planteer{% endblock %} +{% block content %} +
+
+
+ Contact +

Contact Us

+

Send us your message and let us know what plants you love.

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+ Plant contact +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/contact/templates/contact/contact_messages.html b/Planteer/Planteer/contact/templates/contact/contact_messages.html new file mode 100644 index 0000000..f3caed9 --- /dev/null +++ b/Planteer/Planteer/contact/templates/contact/contact_messages.html @@ -0,0 +1,25 @@ +{% extends 'main/base.html' %} +{% block title %}Messages | Planteer{% endblock %} +{% block content %} + +
+
+ {% for message in messages %} +
+

{{ message.first_name }} {{ message.last_name }}

+ {{ message.email }} +

{{ message.message }}

+ {{ message.created_at }} +
+ {% empty %} +

No messages yet.

+ {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/contact/templates/contact/email.html b/Planteer/Planteer/contact/templates/contact/email.html new file mode 100644 index 0000000..4ff3917 --- /dev/null +++ b/Planteer/Planteer/contact/templates/contact/email.html @@ -0,0 +1,13 @@ +

Hello {{ name }} 🌿

+ +

Thank you for contacting Planteer.

+ +

We received your message:

+ +
+ {{ message }} +
+ +

We will get back to you soon 💚

+ +

Planteer

\ No newline at end of file diff --git a/Planteer/Planteer/contact/tests.py b/Planteer/Planteer/contact/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Planteer/Planteer/contact/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Planteer/Planteer/contact/urls.py b/Planteer/Planteer/contact/urls.py new file mode 100644 index 0000000..b0e7c17 --- /dev/null +++ b/Planteer/Planteer/contact/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'contact' + +urlpatterns = [ + path('', views.contact_view, name='contact'), + path('messages/', views.contact_messages_view, name='messages'), +] \ No newline at end of file diff --git a/Planteer/Planteer/contact/views.py b/Planteer/Planteer/contact/views.py new file mode 100644 index 0000000..c4d01b5 --- /dev/null +++ b/Planteer/Planteer/contact/views.py @@ -0,0 +1,37 @@ +from django.shortcuts import render, redirect +from django.core.mail import EmailMessage +from django.template.loader import render_to_string +from django.conf import settings +from django.contrib import messages +from .forms import ContactForm +from .models import Contact + + +def contact_view(request): + if request.method == 'POST': + form = ContactForm(request.POST) + + if form.is_valid(): + contact = form.save() + content_html = render_to_string('contact/email.html', { + 'name': contact.first_name, + 'message': contact.message, + }) + email = EmailMessage( + subject='We received your message - Planteer 🌱', + body=content_html, + from_email=settings.EMAIL_HOST_USER, + to=[contact.email], + ) + email.content_subtype = 'html' + email.send() + messages.success(request,"your message is received") + return redirect('contact:contact') + else: + form = ContactForm() + + return render(request, 'contact/contact.html', {'form': form}) + +def contact_messages_view(request): + messages = Contact.objects.all().order_by('-created_at') + return render(request, 'contact/contact_messages.html', {'messages': messages}) \ No newline at end of file diff --git a/Planteer/Planteer/db.sqlite3 b/Planteer/Planteer/db.sqlite3 new file mode 100644 index 0000000..39af754 Binary files /dev/null and b/Planteer/Planteer/db.sqlite3 differ diff --git a/Planteer/Planteer/main/__init__.py b/Planteer/Planteer/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/main/admin.py b/Planteer/Planteer/main/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/Planteer/Planteer/main/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/Planteer/Planteer/main/apps.py b/Planteer/Planteer/main/apps.py new file mode 100644 index 0000000..833bff6 --- /dev/null +++ b/Planteer/Planteer/main/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MainConfig(AppConfig): + name = 'main' diff --git a/Planteer/Planteer/main/migrations/0001_initial.py b/Planteer/Planteer/main/migrations/0001_initial.py new file mode 100644 index 0000000..7a58388 --- /dev/null +++ b/Planteer/Planteer/main/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-18 15:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=50, validators=[django.core.validators.MinLengthValidator(2)])), + ('last_name', models.CharField(max_length=50, validators=[django.core.validators.MinLengthValidator(2)])), + ('email', models.EmailField(max_length=254)), + ('message', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/Planteer/Planteer/main/migrations/__init__.py b/Planteer/Planteer/main/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/main/models.py b/Planteer/Planteer/main/models.py new file mode 100644 index 0000000..d3fe04f --- /dev/null +++ b/Planteer/Planteer/main/models.py @@ -0,0 +1,14 @@ +from django.db import models +from django.core.validators import MinLengthValidator + +# Create your models here. + +class Contact(models.Model): + first_name = models.CharField(max_length=50, validators=[MinLengthValidator(2)]) + last_name = models.CharField(max_length=50, validators=[MinLengthValidator(2)]) + email = models.EmailField() + message = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.first_name} {self.last_name}" \ No newline at end of file diff --git a/Planteer/Planteer/main/static/css/style.css b/Planteer/Planteer/main/static/css/style.css new file mode 100644 index 0000000..f318412 --- /dev/null +++ b/Planteer/Planteer/main/static/css/style.css @@ -0,0 +1,1536 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --green-5: #355b36; + --shadow: 0 14px 32px rgba(44, 60, 40, 0.08); +} + +body { + font-family: Arial, sans-serif; + background: + radial-gradient(circle at top left, rgba(240, 220, 125, 0.18), transparent 22%), + radial-gradient(circle at top right, rgba(183, 207, 146, 0.16), transparent 24%), + linear-gradient(to bottom, #faf7ef, #f6f3e9); + color: #243126; + line-height: 1.6; +} + +img { + width: 100%; + display: block; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.container { + width: min(1180px, 92%); + margin: 0 auto; +} + +.site-header { + position: sticky; + top: 0; + z-index: 1000; + background: rgba(255, 250, 242, 0.88); + backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(230, 229, 216, 0.9); +} + +.navbar-container { + min-height: 86px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 40px; +} + +.logo { + font-size: 2rem; + font-weight: 800; + color: var(--green-5); + letter-spacing: 0.4px; + margin: 0; + line-height: 1; +} + +.nav-links { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.nav-links a { + color: #475346; + transition: 0.25s ease; + font-weight: 500; +} + +.nav-links a:hover { + color: var(--green-5); +} + +.menu-btn { + display: none; + font-size: 1.6rem; + cursor: pointer; + margin: 0; + padding: 0; + line-height: 1; +} + +.site-footer { + background: linear-gradient(180deg, #edf2e4, #dbe8c9); + border-top: 1px solid #d9e1cb; + margin-top: 40px; + padding: 50px 0 20px; +} + +.footer-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 28px; + + width: min(1180px, 92%); + margin: 0 auto; + + text-align: center; + justify-items: center; +} + +.footer-logo { + font-size: 1.8rem; + color: var(--green-5); + margin-bottom: 10px; +} + +.footer-text { + color: #556053; + max-width: 320px; + line-height: 1.6; +} + +.site-footer h4 { + margin-bottom: 12px; + color: var(--green-5); +} + +.footer-links { + list-style: none; + padding: 0; +} + +.footer-links li { + margin-top: 8px; +} + +.footer-links a { + color: #51604f; + transition: 0.2s; +} + +.footer-links a:hover { + color: var(--green-5); +} + +.footer-bottom { + text-align: center; + margin-top: 30px; + padding-top: 15px; + border-top: 1px solid #d9e1cb; + color: #5f6b5d; + font-size: 0.9rem; +} + +.home-section { + position: relative; + height: 95vh; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.bg-video { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + object-fit: cover; + transform: translate(-50%, -50%); + z-index: 1; +} + +.video-overlay { + position: absolute; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.6); + z-index: 2; +} + +.home-content { + position: relative; + z-index: 3; + text-align: center; +} + +.home-title { + font-size: 4.5rem; + font-weight: 800; + margin-bottom: 10px; + letter-spacing: -1px; +} + +.home-text { + font-size: 2rem; + margin-bottom: 30px; + font-weight: 500; +} + +.search-box { + display: flex; + justify-content: center; + gap: 10px; +} + +.search-box input { + width: 500px; + max-width: 90%; + padding: 15px 20px; + border-radius: 12px; + border: 1px solid #ddd; + outline: none; + font-size: 1rem; +} + +.search-box button { + padding: 15px 25px; + background: black; + color: white; + border: none; + border-radius: 10px; + cursor: pointer; + font-weight: 600; +} + +.section-space { + padding: 28px 0 62px; +} + +.section-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + margin-bottom: 24px; +} + +.mini-tag { + display: inline-block; + font-size: 0.8rem; + font-weight: 600; + color: var(--green-5); + background: none; + border: none; + padding: 0; + border-bottom: 2px solid #7ea66a; + padding-bottom: 2px; + letter-spacing: 0.3px; +} + +.section-header h2 { + font-size: clamp(1.8rem, 2vw, 2.7rem); + margin-top: 8px; +} + +.section-link { + color: var(--green-5); + font-weight: 800; + margin-bottom: 6px; + +} + +.filter-bar input, +.filter-bar select, +.site-form input, +.site-form select { + width: 100%; + padding: 14px 18px; + border: 1px solid #dde3d4; + background: rgba(255, 255, 255, 0.92); + outline: none; +} + +.filter-bar button, +.primary-btn, +.secondary-btn, +.danger-btn { + border: none; + cursor: pointer; + transition: 0.25s ease; +} + +.filter-bar button, +.primary-btn { + background: linear-gradient(135deg, #4d774e, var(--green-5)); + color: #ffffff; + padding: 14px 22px; + border-radius: 999px; + box-shadow: var(--shadow); +} + +.filter-bar button:hover, +.primary-btn:hover { + transform: translateY(-1px); + opacity: 0.97; +} + +.secondary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #fffdf8, #f6f2e7); + color: #243126; + padding: 14px 22px; + border-radius: 999px; + border: 1px solid #e8dfc7; +} + +.secondary-btn:hover { + background: #fff; +} + +.danger-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #ba564c, #98372f); + color: #ffffff; + padding: 14px 22px; + border-radius: 999px; +} + +.page-banner { + padding: 38px 0 28px; +} + +.page-banner .container { + border-radius: 30px; + padding: 42px; + box-shadow: var(--shadow); +} + +.banner-green .container { + background: + linear-gradient(135deg, rgba(220, 233, 201, 0.95), rgba(183, 207, 146, 0.95)); +} + +.banner-yellow .container { + background: + linear-gradient(135deg, rgba(247, 239, 184, 0.95), rgba(240, 220, 125, 0.95)); +} + +.page-banner h1 { + font-size: clamp(2rem, 3vw, 3.5rem); + margin: 10px 0 8px; +} + +.page-banner p { + color: #51604f; +} + +.filter-bar { + display: flex; + gap: 14px; + flex-wrap: wrap; + padding: 18px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid #e7e4d8; + border-radius: 24px; + margin-bottom: 28px; + box-shadow: var(--shadow); +} + +.filter-bar select, +.filter-bar input { + flex: 1; + min-width: 220px; + border-radius: 16px; +} + +.filter-bar button { + padding: 14px 22px; + border-radius: 16px; +} + +.search-only input { + min-width: 320px; +} + +.plant-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 22px; +} + +.plant-card, +.form-card, +.delete-card, +.contact-form-card, +.contact-image-card, +.detail-image, +.detail-info { + background: rgba(255, 255, 255, 0.88); + border-radius: 24px; + box-shadow: var(--shadow); + border: 1px solid rgba(231, 228, 216, 0.95); +} + +.plant-card { + overflow: hidden; + transition: transform 0.25s ease, box-shadow 0.25s ease; +} + +.plant-card:hover { + transform: translateY(-6px); + box-shadow: 0 20px 38px rgba(44, 60, 40, 0.11); +} + +.plant-card-image { + display: block; + background: linear-gradient(180deg, #f8f6ef, #eef3e5); +} + +.plant-card-image img { + height: 250px; + object-fit: cover; +} + +.plant-card-body { + padding: 18px 18px 22px; +} + +.plant-category { + display: inline-block; + font-size: 0.84rem; + color: var(--green-5); + margin-bottom: 10px; + text-transform: capitalize; + font-weight: 700; + border-bottom: 2px solid var(--green-5); +} + + +.card-link { + display: inline-block; + margin-top: 14px; + color: var(--green-5); + font-weight: 800; +} + + +.detail-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 34px; + align-items: stretch; +} + +.detail-image { + overflow: hidden; +} + +.detail-image img { + height: 100%; + width: 100%; + object-fit: cover; +} + +.detail-info { + padding: 34px; +} + +.detail-info h1 { + font-size: clamp(2rem, 3vw, 3rem); + margin: 12px 0 18px; +} + +.detail-meta { + padding: 18px 0; + border-top: 1px solid #ece7d7; + border-bottom: 1px solid #ece7d7; + margin-bottom: 22px; +} + +.detail-meta p+p { + margin-top: 8px; +} + +.detail-text-block+.detail-text-block { + margin-top: 18px; +} + +.detail-text-block h3 { + margin-bottom: 8px; +} + +.detail-text-block p { + color: #6b7567; +} + +.action-row { + margin-top: 24px; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.related-wrapper { + padding-top: 0; +} + +.form-section-wrap { + padding: 36px 0 70px; +} + +.form-card, +.delete-card { + max-width: 780px; + margin: 0 auto; + padding: 32px; +} + +.site-form p { + margin-bottom: 18px; +} + +.site-form label { + display: block; + font-weight: 700; + margin-bottom: 8px; + color: #3d4c3b; +} + +.site-form input, +.site-form select { + border-radius: 18px; + padding: 14px 16px; + background: #fffef9; +} + +.site-form textarea { + min-height: 140px; + resize: vertical; + border: 1px solid #dde3d4; + padding: 14px 16px; + background: #fffef9; + width: 100%; + outline: none; + border-radius: 20px; +} + +.site-form input[type="checkbox"] { + width: auto; + margin-right: 8px; +} + +.form-btn { + margin-top: 8px; +} + +.delete-card { + text-align: center; + background: linear-gradient(180deg, #fffdfa, #f8f4eb); +} + +.delete-card h1 { + margin: 12px 0; +} + +.delete-card p { + color: #6b7567; +} + +.delete-actions { + margin-top: 22px; + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.contact-layout { + display: grid; + grid-template-columns: 1fr 0.95fr; + gap: 28px; + align-items: stretch; +} + +.contact-form-card, +.contact-image-card { + padding: 28px; +} + +.contact-form-card h1 { + margin: 10px 0 6px; +} + +.contact-form-card p { + color: #6b7567; + margin-bottom: 18px; +} + +.contact-image-card { + overflow: hidden; + padding: 0; + background: linear-gradient(135deg, #dce9c9, #f7efb8); +} + +.contact-image-card img { + height: 100%; + min-height: 100%; + object-fit: cover; +} + +.message-grid { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 16px; + align-items: stretch; +} + +.message-card { + padding: 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + border: 1px solid #ddd8ca; + border-radius: 24px; + background: #fffef9; +} + +.message-card h3 { + margin-bottom: 10px; + font-size: 1.3rem; +} + +.message-email { + color: var(--green-5); + font-weight: 800; + display: inline-block; + margin-bottom: 10px; +} + +.message-card p { + color: #6b7567; + margin: 0; + line-height: 1.7; + font-size: 0.95rem; +} + +.review-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + align-items: stretch; +} + +.review-form-box, +.review-comments-box { + background: rgba(255, 255, 255, 0.88); + border-radius: 24px; + padding: 28px; + border: 1px solid #e7e4d8; + box-shadow: var(--shadow); + height: 100%; +} + +.review-comments-box .message-grid { + margin-top: 10px; +} + +.country-list { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 12px; +} + +.country-item { + display: flex; + align-items: center; + gap: 8px; + background: #f8f8f8; + padding: 8px 12px; + border-radius: 10px; +} + +.country-item img { + width: 28px; + height: 20px; + object-fit: cover; + border-radius: 4px; +} + +.detail-info .country-list { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 12px; + margin-bottom: 20px; +} + +.detail-info .country-link { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 110px; + padding: 12px 10px; + background: #f7f7f2; + border-radius: 14px; + text-decoration: none; + color: #2f4f2f; + text-align: center; +} + +.detail-info .country-link img { + width: 70px; + height: 46px; + object-fit: cover; + border-radius: 6px; +} + +.detail-info .country-link span { + font-size: 0.92rem; + font-weight: 700; + line-height: 1.2; +} + +.plant-card-body a { + color: #355B36; + font-weight: 800; + text-decoration: none; +} + +.alert-wrapper { + width: min(1180px, 92%); + margin: 20px auto; +} + +.custom-alert { + padding: 14px 18px; + border-radius: 10px; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.custom-alert.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.custom-alert button { + border: none; + background: transparent; + font-size: 20px; + cursor: pointer; + color: inherit; +} + +.auth-section { + min-height: 80vh; + padding: 60px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-card { + width: min(620px, 92%); + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(231, 228, 216, 0.95); + border-radius: 28px; + padding: 34px; + box-shadow: var(--shadow); +} + +.auth-card h1 { + margin: 12px 0 6px; + font-size: 2.3rem; + color: var(--green-5); +} + +.auth-card p { + color: var(--muted); + margin-bottom: 24px; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.auth-field label { + display: block; + font-weight: 700; + margin-bottom: 8px; + color: #3d4c3b; +} + +.auth-field input { + width: 100%; + padding: 14px 16px; + border: 1px solid #dde3d4; + border-radius: 16px; + background: #fffef9; + outline: none; +} + +.auth-field input:focus { + border-color: var(--green-3); +} + +.auth-btn { + width: 100%; + margin-top: 8px; + border-radius: 16px; +} + +.account-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 50px; +} + +.account-btn .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} + +.account-btn:hover { + background: #eef3e5; +} + +.nav-btn { + background: linear-gradient(135deg, #f0dc7d, #7ea66a); + color: #1e281f; + padding: 12px 20px; + border-radius: 999px; + font-weight: 700; + box-shadow: var(--shadow); + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.login-alert { + margin-top: 20px; + padding: 16px; + border-radius: 16px; + background: #eef3e5; + border: 1px solid #cfe0c3; + color: #355b36; + font-weight: 600; + text-align: center; +} + +.avatar { + width: 35px; + height: 35px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #d4b26a; +} + + +.update-card { + width: 500px; + background: #fff; + padding: 35px; + border-radius: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); +} + +.avatar-preview { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 3px solid #d4b26a; +} + +.profile-section { + padding: 42px 0 90px; +} + +.profile-card { + display: grid; + grid-template-columns: 300px 1fr; + gap: 48px; + align-items: flex-start; + + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(231, 228, 216, 0.95); + border-radius: 34px; + padding: 42px; + box-shadow: 0 18px 45px rgba(44, 60, 40, 0.10); +} + +.profile-left { + text-align: center; + top: 120px; + margin-top: 60px; +} + +.profile-avatar-wrap { + width: 210px; + height: 210px; + margin: 0 auto 18px; + border-radius: 50%; + padding: 6px; + background: linear-gradient(135deg, #d4b26a, #7ea66a); +} + +.profile-avatar { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: 6px solid #fff; +} + +.profile-username { + font-size: 2rem; + font-weight: 700; + color: #1f2b20; + margin-bottom: 20px; +} + +.profile-edit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 12px 28px; + border-radius: 999px; + background: linear-gradient(135deg, #355b36, #244d2d); + color: #fff; + font-weight: 700; + box-shadow: 0 12px 22px rgba(53, 91, 54, 0.22); + transition: 0.25s ease; +} + +.profile-edit-btn:hover { + transform: translateY(-2px); + color: #fff; +} + +.profile-right { + min-width: 0; +} + +.profile-info h1 { + font-size: clamp(2rem, 3vw, 3rem); + color: #355b36; + margin-bottom: 6px; +} + +.profile-email { + font-size: 1.05rem; + color: #243126; + word-break: break-word; +} + +.profile-divider { + width: 100%; + height: 1px; + background: #deded5; + margin: 24px 0; +} + +.about-box h3, +.comments-title { + font-size: 1.9rem; + color: #172117; + margin-bottom: 12px; +} + +.about-box p { + color: #5e685d; + line-height: 1.8; +} + +.comments-list { + display: flex; + flex-direction: column; + gap: 18px; + margin-top: 18px; +} + +.comment-card { + background: #fff; + border: 1px solid #eee8dc; + border-radius: 16px; + padding: 22px 26px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08); + transition: 0.25s ease; +} + +.comment-card:hover { + transform: translateY(-3px); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.11); +} + +.comment-user { + display: inline-block; + font-size: 1.35rem; + font-weight: 800; + color: #1e281f; + margin-bottom: 8px; + text-decoration: none; + +} + +.review-comments-box .message-card .comment-text { + font-size: 1.4rem; + line-height: 1.8; + color: #2a2a2a; +} + +.comment-plant { + font-weight: 800; + color: #1e281f; + margin-bottom: 8px; +} + +.comment-plant a { + color: #355b36; + font-weight: 800; + border-bottom: 2px solid rgba(126, 166, 106, 0.45); +} + +.comment-plant a:hover { + color: #244d2d; +} + +.comment-date { + color: #687066; + font-size: 0.95rem; + margin-top: 8px; +} + +.empty-text { + color: #7a8277; + background: #f7f7f2; + border: 1px dashed #d9d7c9; + padding: 14px 16px; + border-radius: 14px; +} + +.comment-card-with-avatar { + padding: 18px; +} + +.comment-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} + +.comment-avatar-link { + flex-shrink: 0; +} + +.comment-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #d4b26a; + background: #f3f3f3; +} + +.comment-user:hover { + color: #355b36; +} + +.bookmark-btn { + font-size: 28px; + color: #355b36; + transition: 0.2s; +} + +.bookmark-btn:hover { + color: #d4b26a; + transform: scale(1.1); +} + +@media (max-width: 1100px) { + .footer-container { + grid-template-columns: 1fr; + } + + .plant-grid { + grid-template-columns: repeat(3, 1fr); + } + + .detail-layout, + .contact-layout { + grid-template-columns: 1fr; + } + + + .detail-image img { + min-height: 380px; + } + + .contact-image-card img { + min-height: 340px; + } +} + +@media (max-width: 900px) { + .menu-btn { + display: flex; + align-items: center; + justify-content: center; + margin-left: 0; + } + + .nav-links { + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + width: 90%; + background: white; + border-radius: 16px; + padding: 18px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); + display: none; + flex-direction: column; + gap: 12px; + align-items: center; + text-align: center; + } + + .nav-links.show { + display: flex; + } + + .nav-links .dropdown { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + .nav-links .dropdown-menu { + position: static !important; + transform: none !important; + inset: auto !important; + margin: 14px auto 0 !important; + width: 140px; + text-align: center; + } + + .review-layout { + grid-template-columns: 1fr; + } + + .dropdown { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + .dropdown-menu { + position: static; + transform: none; + margin-top: 10px; + width: 160px; + min-width: 160px; + padding: 8px; + border: 2px solid #dfe7d6; + border-radius: 990px; + box-shadow: var(--shadow); + text-align: center; + } + + .dropdown-item { + border-radius: 10px; + color: #355b36; + font-weight: 700; + text-align: center; + padding: 10px 12px; + border: 2px solid #7ea66a; + border-radius: 999px; + } + + .dropdown-item:hover { + background: #eef3e5; + color: #355b36; + } + + .profile-card { + grid-template-columns: 1fr; + padding: 30px; + gap: 30px; + } + + .profile-left { + position: static; + } + + .profile-avatar-wrap { + width: 180px; + height: 180px; + } + + .profile-username { + font-size: 1.7rem; + } +} + +@media (max-width: 800px) { + + .plant-grid { + grid-template-columns: repeat(2, 1fr); + } + + .navbar-container { + padding: 0 20px; + align-items: center; + justify-content: space-between; + flex-direction: row; + position: relative; + } + + .page-banner .container, + .detail-info, + .form-card, + .delete-card, + .contact-form-card { + padding: 24px; + } +} + +@media (max-width: 600px) { + .footer-container { + gap: 20px; + text-align: center; + } + + .footer-text { + margin: 0 auto; + } + + .home-title { + font-size: 2.5rem; + } + + .home-text { + font-size: 1.3rem; + } + + .search-box { + flex-direction: column; + align-items: center; + gap: 10px; + } + + .search-box input, + .search-box button { + width: 100%; + max-width: 320px; + border-radius: 12px; + } + + .search-box button { + padding: 15px 20px; + } + + .auth-card { + padding: 24px; + } + + .auth-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + + .plant-grid, + .message-grid { + grid-template-columns: 1fr; + } + + .filter-bar, + .action-row, + .delete-actions, + .nav-links { + flex-direction: column; + align-items: stretch; + } + + .filter-bar button, + .primary-btn, + .secondary-btn, + .danger-btn, + .nav-btn { + width: 100%; + text-align: center; + } + + .page-banner h1, + .detail-info h1 { + font-size: 1.9rem; + } + + .profile-section { + padding: 24px 0 60px; + } + + .profile-card { + padding: 22px; + border-radius: 24px; + } + + .profile-avatar-wrap { + width: 150px; + height: 150px; + } + + .about-box h3, + .comments-title { + font-size: 1.5rem; + } + + .comment-card { + padding: 18px; + } +} + +.dropdown-menu { + border: 1px solid #dfe7d6; + border-radius: 18px; + padding: 10px; + min-width: 180px; + background: #fffef9; + box-shadow: 0 14px 32px rgba(44, 60, 40, 0.12); + margin-top: 5px !important; +} + +.dropdown-item { + color: #355b36; + font-weight: 700; + border-radius: 12px; + padding: 10px 14px; + transition: 0.2s ease; +} + +.dropdown-item:hover { + background: #eef3e5; + color: #244d2d; +} + +.auth-field textarea { + width: 100%; + min-height: 100px; + padding: 14px 16px; + border: 1px solid #dde3d4; + border-radius: 16px; + background: #fffef9; + outline: none; + resize: vertical; +} + +.auth-field input.input-error, +.auth-field textarea.input-error { + border: 2px solid #d93025 !important; + background: #fff5f5 !important; +} + +.auth-field input.input-error:focus, +.auth-field textarea.input-error:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(217, 48, 37, 0.15); +} + +.error-text { + color: #d93025; + font-size: 0.85rem; + margin-top: 4px; + display: block; +} + +.error-box { + background: #fff5f5; + border: 1px solid #f5c2c7; + color: #d93025; + padding: 10px 14px; + border-radius: 10px; + margin-bottom: 15px; + font-weight: 600; + text-align: center; +} + +.password-wrapper { + position: relative; +} + +.password-wrapper input { + width: 100%; + padding-right: 45px; +} + +.toggle-password { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + cursor: pointer; + font-size: 18px; + color: #355b36; +} + +.toggle-password:hover { + color: #dc1f0d; +} + +.forgot-password { + text-align: left; + margin-top: 6px; +} + +.forgot-password a { + font-size: 0.85rem; + color: #6c8c5c; + text-decoration: none; +} + +.forgot-password a:hover { + text-decoration: none; + color: #d62e1f; +} + +.pagination-box { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 30px; +} + +.pagination-box a { + padding: 10px 18px; + border-radius: 999px; + background: #355b36; + color: white; + font-weight: 700; +} + +.pagination-box span { + font-weight: 700; + color: #355b36; +} + +.profile-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin: 24px 0; +} + +.profile-stats div { + background: #f7f7f2; + border: 1px solid #e7e4d8; + border-radius: 16px; + padding: 16px; + text-align: center; +} + +.profile-stats strong { + display: block; + color: #355b36; + font-size: 1.4rem; +} + +.profile-stats span { + color: #6b7567; + font-size: 0.9rem; +} + +.stats-section { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; + margin: 30px auto; +} + +.stats-card { + background: #fffef9; + border: 1px solid #e7e4d8; + border-radius: 20px; + padding: 24px; + text-align: center; + box-shadow: var(--shadow); +} + +.stats-card strong { + display: block; + font-size: 2rem; + color: #355b36; +} + +.stats-card span { + color: #6b7567; +} + +@media (max-width: 700px) { + + .stats-section, + .profile-stats { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/Planteer/Planteer/main/static/js/script.js b/Planteer/Planteer/main/static/js/script.js new file mode 100644 index 0000000..a447eda --- /dev/null +++ b/Planteer/Planteer/main/static/js/script.js @@ -0,0 +1,29 @@ +document.addEventListener("DOMContentLoaded", function () { + + const menuBtn = document.getElementById("menuBtn"); + const navLinks = document.getElementById("navLinks"); + + if (menuBtn && navLinks) { + menuBtn.addEventListener("click", function () { + navLinks.classList.toggle("show"); + }); + } + + const togglePassword = document.getElementById("togglePassword"); + const passwordInput = document.getElementById("passwordInput"); + + if (togglePassword && passwordInput) { + togglePassword.addEventListener("click", function () { + const icon = this.querySelector("i"); + + if (passwordInput.type === "password") { + passwordInput.type = "text"; + icon.classList.replace("bi-eye", "bi-eye-slash"); + } else { + passwordInput.type = "password"; + icon.classList.replace("bi-eye-slash", "bi-eye"); + } + }); + } + +}); \ No newline at end of file diff --git a/Planteer/Planteer/main/static/videos/header.mp4 b/Planteer/Planteer/main/static/videos/header.mp4 new file mode 100644 index 0000000..6ffc8f1 Binary files /dev/null and b/Planteer/Planteer/main/static/videos/header.mp4 differ diff --git a/Planteer/Planteer/main/templates/main/base.html b/Planteer/Planteer/main/templates/main/base.html new file mode 100644 index 0000000..0919bc2 --- /dev/null +++ b/Planteer/Planteer/main/templates/main/base.html @@ -0,0 +1,112 @@ +{% load static %} + + + + + + + {% block title %}Planteer{% endblock %} + + + + + + + + {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} + +
+ {% endfor %} +
+ {% endif %} +
+ {% block content %}{% endblock %} +
+
+ + +
+ + + + + + \ No newline at end of file diff --git a/Planteer/Planteer/main/templates/main/home.html b/Planteer/Planteer/main/templates/main/home.html new file mode 100644 index 0000000..3ad994e --- /dev/null +++ b/Planteer/Planteer/main/templates/main/home.html @@ -0,0 +1,60 @@ +{% extends 'main/base.html' %} +{% load static %} + +{% block title %}Home | Planteer{% endblock %} + + +{% block content %} + +
+ + + +
+ +
+

Planteer

+

Plant Database For Plants Lovers

+ + +
+ +
+ +
+
+ {{ plants_count }} + Total Plants +
+ +
+ {{ comments_count }} + Total Comments +
+ +
+ {{ countries_count }} + Countries +
+
+ +
+
+
+ Featured +

Latest Plants

+
+ View all +
+
+ {% for plant in plants %} + {% include 'plants/includes/plant_card.html' %} + {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/main/tests.py b/Planteer/Planteer/main/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Planteer/Planteer/main/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Planteer/Planteer/main/urls.py b/Planteer/Planteer/main/urls.py new file mode 100644 index 0000000..dac7144 --- /dev/null +++ b/Planteer/Planteer/main/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +app_name = 'main' + +urlpatterns = [ + path('', views.home_view, name='home'), +] \ No newline at end of file diff --git a/Planteer/Planteer/main/views.py b/Planteer/Planteer/main/views.py new file mode 100644 index 0000000..6d149b9 --- /dev/null +++ b/Planteer/Planteer/main/views.py @@ -0,0 +1,18 @@ +from django.shortcuts import render +from plants.models import Plant,Comment, Country + + +def home_view(request): + + if request.user.is_authenticated: + print(request.user.email) + else: + print("User is not logged in") + + + plants = Plant.objects.all().order_by('-created_at')[:8] + return render(request, 'main/home.html', { + 'plants': plants, + 'plants_count': Plant.objects.count(), + 'comments_count': Comment.objects.count(), + 'countries_count': Country.objects.count(),}) diff --git a/Planteer/Planteer/manage.py b/Planteer/Planteer/manage.py new file mode 100644 index 0000000..bd0a64a --- /dev/null +++ b/Planteer/Planteer/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Planteer.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/Planteer/Planteer/media/countries/France.png b/Planteer/Planteer/media/countries/France.png new file mode 100644 index 0000000..aa21452 Binary files /dev/null and b/Planteer/Planteer/media/countries/France.png differ diff --git a/Planteer/Planteer/media/countries/Germany.png b/Planteer/Planteer/media/countries/Germany.png new file mode 100644 index 0000000..109cf8b Binary files /dev/null and b/Planteer/Planteer/media/countries/Germany.png differ diff --git a/Planteer/Planteer/media/countries/Greece.png b/Planteer/Planteer/media/countries/Greece.png new file mode 100644 index 0000000..f3a41e1 Binary files /dev/null and b/Planteer/Planteer/media/countries/Greece.png differ diff --git a/Planteer/Planteer/media/countries/India.png b/Planteer/Planteer/media/countries/India.png new file mode 100644 index 0000000..9f291fc Binary files /dev/null and b/Planteer/Planteer/media/countries/India.png differ diff --git a/Planteer/Planteer/media/countries/Italy.png b/Planteer/Planteer/media/countries/Italy.png new file mode 100644 index 0000000..c078e50 Binary files /dev/null and b/Planteer/Planteer/media/countries/Italy.png differ diff --git a/Planteer/Planteer/media/countries/Mexico.png b/Planteer/Planteer/media/countries/Mexico.png new file mode 100644 index 0000000..d8dfba0 Binary files /dev/null and b/Planteer/Planteer/media/countries/Mexico.png differ diff --git a/Planteer/Planteer/media/countries/Netherlands.png b/Planteer/Planteer/media/countries/Netherlands.png new file mode 100644 index 0000000..d0be032 Binary files /dev/null and b/Planteer/Planteer/media/countries/Netherlands.png differ diff --git a/Planteer/Planteer/media/countries/Peru.png b/Planteer/Planteer/media/countries/Peru.png new file mode 100644 index 0000000..3b1c761 Binary files /dev/null and b/Planteer/Planteer/media/countries/Peru.png differ diff --git a/Planteer/Planteer/media/countries/Saudi_Arabia.jpg b/Planteer/Planteer/media/countries/Saudi_Arabia.jpg new file mode 100644 index 0000000..f4e20c8 Binary files /dev/null and b/Planteer/Planteer/media/countries/Saudi_Arabia.jpg differ diff --git a/Planteer/Planteer/media/countries/United_Kingdom.png b/Planteer/Planteer/media/countries/United_Kingdom.png new file mode 100644 index 0000000..6593da6 Binary files /dev/null and b/Planteer/Planteer/media/countries/United_Kingdom.png differ diff --git a/Planteer/Planteer/media/countries/United_Kingdom_QR4Ry6f.png b/Planteer/Planteer/media/countries/United_Kingdom_QR4Ry6f.png new file mode 100644 index 0000000..6593da6 Binary files /dev/null and b/Planteer/Planteer/media/countries/United_Kingdom_QR4Ry6f.png differ diff --git a/Planteer/Planteer/media/countries/United_States.png b/Planteer/Planteer/media/countries/United_States.png new file mode 100644 index 0000000..a85b087 Binary files /dev/null and b/Planteer/Planteer/media/countries/United_States.png differ diff --git a/Planteer/Planteer/media/images/avatars/Greece.png b/Planteer/Planteer/media/images/avatars/Greece.png new file mode 100644 index 0000000..f3a41e1 Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/Greece.png differ diff --git a/Planteer/Planteer/media/images/avatars/Jasmine.jpg b/Planteer/Planteer/media/images/avatars/Jasmine.jpg new file mode 100644 index 0000000..604b56a Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/Jasmine.jpg differ diff --git a/Planteer/Planteer/media/images/avatars/Jasmine_IrNVtaq.jpg b/Planteer/Planteer/media/images/avatars/Jasmine_IrNVtaq.jpg new file mode 100644 index 0000000..604b56a Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/Jasmine_IrNVtaq.jpg differ diff --git a/Planteer/Planteer/media/images/avatars/Lavender.jpg b/Planteer/Planteer/media/images/avatars/Lavender.jpg new file mode 100644 index 0000000..5177609 Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/Lavender.jpg differ diff --git a/Planteer/Planteer/media/images/avatars/Sunflower.jpg b/Planteer/Planteer/media/images/avatars/Sunflower.jpg new file mode 100644 index 0000000..140e8e7 Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/Sunflower.jpg differ diff --git a/Planteer/Planteer/media/images/avatars/Tulip.jpg b/Planteer/Planteer/media/images/avatars/Tulip.jpg new file mode 100644 index 0000000..c585f9d Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/Tulip.jpg differ diff --git a/Planteer/Planteer/media/images/avatars/avatar.png b/Planteer/Planteer/media/images/avatars/avatar.png new file mode 100644 index 0000000..ee240d0 Binary files /dev/null and b/Planteer/Planteer/media/images/avatars/avatar.png differ diff --git a/Planteer/Planteer/media/plants/Aloe_Vera.jpg b/Planteer/Planteer/media/plants/Aloe_Vera.jpg new file mode 100644 index 0000000..c51baa0 Binary files /dev/null and b/Planteer/Planteer/media/plants/Aloe_Vera.jpg differ diff --git a/Planteer/Planteer/media/plants/Basil.jpg b/Planteer/Planteer/media/plants/Basil.jpg new file mode 100644 index 0000000..f25154f Binary files /dev/null and b/Planteer/Planteer/media/plants/Basil.jpg differ diff --git a/Planteer/Planteer/media/plants/Cactus.jpg b/Planteer/Planteer/media/plants/Cactus.jpg new file mode 100644 index 0000000..9bb90e6 Binary files /dev/null and b/Planteer/Planteer/media/plants/Cactus.jpg differ diff --git a/Planteer/Planteer/media/plants/Carrot.jpg b/Planteer/Planteer/media/plants/Carrot.jpg new file mode 100644 index 0000000..08330ca Binary files /dev/null and b/Planteer/Planteer/media/plants/Carrot.jpg differ diff --git a/Planteer/Planteer/media/plants/Carrot_JRhZQld.jpg b/Planteer/Planteer/media/plants/Carrot_JRhZQld.jpg new file mode 100644 index 0000000..08330ca Binary files /dev/null and b/Planteer/Planteer/media/plants/Carrot_JRhZQld.jpg differ diff --git a/Planteer/Planteer/media/plants/Carrot_zKzTi44.jpg b/Planteer/Planteer/media/plants/Carrot_zKzTi44.jpg new file mode 100644 index 0000000..08330ca Binary files /dev/null and b/Planteer/Planteer/media/plants/Carrot_zKzTi44.jpg differ diff --git a/Planteer/Planteer/media/plants/Daisy.jpg b/Planteer/Planteer/media/plants/Daisy.jpg new file mode 100644 index 0000000..49c418a Binary files /dev/null and b/Planteer/Planteer/media/plants/Daisy.jpg differ diff --git a/Planteer/Planteer/media/plants/Daisy_WpiN7b1.jpg b/Planteer/Planteer/media/plants/Daisy_WpiN7b1.jpg new file mode 100644 index 0000000..49c418a Binary files /dev/null and b/Planteer/Planteer/media/plants/Daisy_WpiN7b1.jpg differ diff --git a/Planteer/Planteer/media/plants/Greece.png b/Planteer/Planteer/media/plants/Greece.png new file mode 100644 index 0000000..f3a41e1 Binary files /dev/null and b/Planteer/Planteer/media/plants/Greece.png differ diff --git a/Planteer/Planteer/media/plants/Jasmine.jpg b/Planteer/Planteer/media/plants/Jasmine.jpg new file mode 100644 index 0000000..604b56a Binary files /dev/null and b/Planteer/Planteer/media/plants/Jasmine.jpg differ diff --git a/Planteer/Planteer/media/plants/Jasmine_s5uxBTO.jpg b/Planteer/Planteer/media/plants/Jasmine_s5uxBTO.jpg new file mode 100644 index 0000000..604b56a Binary files /dev/null and b/Planteer/Planteer/media/plants/Jasmine_s5uxBTO.jpg differ diff --git a/Planteer/Planteer/media/plants/Lavender.jpg b/Planteer/Planteer/media/plants/Lavender.jpg new file mode 100644 index 0000000..5177609 Binary files /dev/null and b/Planteer/Planteer/media/plants/Lavender.jpg differ diff --git a/Planteer/Planteer/media/plants/Lemon_Tree.jpg b/Planteer/Planteer/media/plants/Lemon_Tree.jpg new file mode 100644 index 0000000..5e42246 Binary files /dev/null and b/Planteer/Planteer/media/plants/Lemon_Tree.jpg differ diff --git a/Planteer/Planteer/media/plants/Mint.jpg b/Planteer/Planteer/media/plants/Mint.jpg new file mode 100644 index 0000000..9cdd6a2 Binary files /dev/null and b/Planteer/Planteer/media/plants/Mint.jpg differ diff --git a/Planteer/Planteer/media/plants/Monstera.jpg b/Planteer/Planteer/media/plants/Monstera.jpg new file mode 100644 index 0000000..bc9ead6 Binary files /dev/null and b/Planteer/Planteer/media/plants/Monstera.jpg differ diff --git a/Planteer/Planteer/media/plants/Olive_tree.jpg b/Planteer/Planteer/media/plants/Olive_tree.jpg new file mode 100644 index 0000000..5a12194 Binary files /dev/null and b/Planteer/Planteer/media/plants/Olive_tree.jpg differ diff --git a/Planteer/Planteer/media/plants/Olive_tree_i7jkzQI.jpg b/Planteer/Planteer/media/plants/Olive_tree_i7jkzQI.jpg new file mode 100644 index 0000000..5a12194 Binary files /dev/null and b/Planteer/Planteer/media/plants/Olive_tree_i7jkzQI.jpg differ diff --git a/Planteer/Planteer/media/plants/Peace_Lily.jpg b/Planteer/Planteer/media/plants/Peace_Lily.jpg new file mode 100644 index 0000000..43e0a25 Binary files /dev/null and b/Planteer/Planteer/media/plants/Peace_Lily.jpg differ diff --git a/Planteer/Planteer/media/plants/Peace_Lily_kshRuzH.jpg b/Planteer/Planteer/media/plants/Peace_Lily_kshRuzH.jpg new file mode 100644 index 0000000..43e0a25 Binary files /dev/null and b/Planteer/Planteer/media/plants/Peace_Lily_kshRuzH.jpg differ diff --git a/Planteer/Planteer/media/plants/Rose.jpg b/Planteer/Planteer/media/plants/Rose.jpg new file mode 100644 index 0000000..78dc4e8 Binary files /dev/null and b/Planteer/Planteer/media/plants/Rose.jpg differ diff --git a/Planteer/Planteer/media/plants/Rosemary.jpg b/Planteer/Planteer/media/plants/Rosemary.jpg new file mode 100644 index 0000000..a470ea7 Binary files /dev/null and b/Planteer/Planteer/media/plants/Rosemary.jpg differ diff --git a/Planteer/Planteer/media/plants/Strawberry.jpg b/Planteer/Planteer/media/plants/Strawberry.jpg new file mode 100644 index 0000000..3e3a761 Binary files /dev/null and b/Planteer/Planteer/media/plants/Strawberry.jpg differ diff --git a/Planteer/Planteer/media/plants/Strawberry_x9vmpsp.jpg b/Planteer/Planteer/media/plants/Strawberry_x9vmpsp.jpg new file mode 100644 index 0000000..3e3a761 Binary files /dev/null and b/Planteer/Planteer/media/plants/Strawberry_x9vmpsp.jpg differ diff --git a/Planteer/Planteer/media/plants/Sunflower.jpg b/Planteer/Planteer/media/plants/Sunflower.jpg new file mode 100644 index 0000000..140e8e7 Binary files /dev/null and b/Planteer/Planteer/media/plants/Sunflower.jpg differ diff --git a/Planteer/Planteer/media/plants/Tomato_Plant.jpg b/Planteer/Planteer/media/plants/Tomato_Plant.jpg new file mode 100644 index 0000000..e6b517b Binary files /dev/null and b/Planteer/Planteer/media/plants/Tomato_Plant.jpg differ diff --git a/Planteer/Planteer/media/plants/Tulip.jpg b/Planteer/Planteer/media/plants/Tulip.jpg new file mode 100644 index 0000000..c585f9d Binary files /dev/null and b/Planteer/Planteer/media/plants/Tulip.jpg differ diff --git a/Planteer/Planteer/media/plants/Tulip_bO478vr.jpg b/Planteer/Planteer/media/plants/Tulip_bO478vr.jpg new file mode 100644 index 0000000..c585f9d Binary files /dev/null and b/Planteer/Planteer/media/plants/Tulip_bO478vr.jpg differ diff --git a/Planteer/Planteer/media/plants/b11f558e74ea48bb54a52e91c7342730.jpg b/Planteer/Planteer/media/plants/b11f558e74ea48bb54a52e91c7342730.jpg new file mode 100644 index 0000000..a4ec598 Binary files /dev/null and b/Planteer/Planteer/media/plants/b11f558e74ea48bb54a52e91c7342730.jpg differ diff --git a/Planteer/Planteer/plants/__init__.py b/Planteer/Planteer/plants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/plants/admin.py b/Planteer/Planteer/plants/admin.py new file mode 100644 index 0000000..a82692f --- /dev/null +++ b/Planteer/Planteer/plants/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from .models import Plant, Comment, Country + + +class PlantAdmin(admin.ModelAdmin): + list_display = ["name", "category", "is_edible", "created_at"] + list_filter = ["category", "is_edible"] + +class CommentAdmin(admin.ModelAdmin): + list_display = ["user", "plant", "created_at"] + list_filter = ["plant"] + +class CountryAdmin(admin.ModelAdmin): + list_display = ["name"] + + +admin.site.register(Plant, PlantAdmin) +admin.site.register(Comment, CommentAdmin) +admin.site.register(Country, CountryAdmin) \ No newline at end of file diff --git a/Planteer/Planteer/plants/apps.py b/Planteer/Planteer/plants/apps.py new file mode 100644 index 0000000..478c3ce --- /dev/null +++ b/Planteer/Planteer/plants/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PlantsConfig(AppConfig): + name = 'plants' diff --git a/Planteer/Planteer/plants/forms.py b/Planteer/Planteer/plants/forms.py new file mode 100644 index 0000000..b8c7260 --- /dev/null +++ b/Planteer/Planteer/plants/forms.py @@ -0,0 +1,35 @@ +from django import forms +from .models import Plant + + +class PlantForm(forms.ModelForm): + class Meta: + model = Plant + fields = ['name', 'about', 'used_for', 'image', 'category', 'is_edible'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Plant name', 'minlength': '2', 'required': True}), + 'about': forms.Textarea(attrs={'class': 'form-control', 'placeholder': 'About plant', 'rows': 4, 'required': True}), + 'used_for': forms.Textarea(attrs={'class': 'form-control', 'placeholder': 'Used for', 'rows': 4, 'required': True}), + 'category': forms.Select(attrs={'class': 'form-control', 'required': True}), + 'is_edible': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'countries': forms.CheckboxSelectMultiple(), + + } + + def clean_name(self): + name = self.cleaned_data['name'].strip() + if len(name) < 2: + raise forms.ValidationError("Plant name must be at least 2 characters.") + return name + + def clean_about(self): + about = self.cleaned_data['about'].strip() + if len(about) < 10: + raise forms.ValidationError("About must be at least 10 characters.") + return about + + def clean_used_for(self): + used_for = self.cleaned_data['used_for'].strip() + if len(used_for) < 5: + raise forms.ValidationError("Used for must be at least 5 characters.") + return used_for \ No newline at end of file diff --git a/Planteer/Planteer/plants/migrations/0001_initial.py b/Planteer/Planteer/plants/migrations/0001_initial.py new file mode 100644 index 0000000..3f03bbe --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-04-18 15:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Plant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, validators=[django.core.validators.MinLengthValidator(2)])), + ('about', models.TextField()), + ('used_for', models.TextField()), + ('image', models.ImageField(upload_to='plants/')), + ('category', models.CharField(choices=[('flower', 'Flower'), ('fruit', 'Fruit'), ('vegetable', 'Vegetable'), ('herb', 'Herb'), ('tree', 'Tree'), ('cactus', 'Cactus')], max_length=20)), + ('is_edible', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0002_review.py b/Planteer/Planteer/plants/migrations/0002_review.py new file mode 100644 index 0000000..ce37dd0 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0002_review.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.3 on 2026-04-20 08:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('rating', models.SmallIntegerField()), + ('comment', models.TextField()), + ('craeted_at', models.DateTimeField(auto_now_add=True)), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.plant')), + ], + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0003_rename_craeted_at_review_created_at.py b/Planteer/Planteer/plants/migrations/0003_rename_craeted_at_review_created_at.py new file mode 100644 index 0000000..5bb3c9f --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0003_rename_craeted_at_review_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-20 08:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0002_review'), + ] + + operations = [ + migrations.RenameField( + model_name='review', + old_name='craeted_at', + new_name='created_at', + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0004_commint_delete_review.py b/Planteer/Planteer/plants/migrations/0004_commint_delete_review.py new file mode 100644 index 0000000..0a83106 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0004_commint_delete_review.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.3 on 2026-04-20 08:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0003_rename_craeted_at_review_created_at'), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('comment', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.plant')), + ], + ), + migrations.DeleteModel( + name='Review', + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0005_category.py b/Planteer/Planteer/plants/migrations/0005_category.py new file mode 100644 index 0000000..511c4bb --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0005_category.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-04-21 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0004_commint_delete_review'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, unique=True)), + ], + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0006_rename_category_country_plant_categories.py b/Planteer/Planteer/plants/migrations/0006_rename_category_country_plant_categories.py new file mode 100644 index 0000000..b0aa4fb --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0006_rename_category_country_plant_categories.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.3 on 2026-04-21 06:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0005_category'), + ] + + operations = [ + migrations.RenameModel( + old_name='Category', + new_name='Country', + ), + migrations.AddField( + model_name='plant', + name='categories', + field=models.ManyToManyField(to='plants.country'), + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0007_remove_plant_categories_country_flag_plant_countries_and_more.py b/Planteer/Planteer/plants/migrations/0007_remove_plant_categories_country_flag_plant_countries_and_more.py new file mode 100644 index 0000000..8c2967e --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0007_remove_plant_categories_country_flag_plant_countries_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.3 on 2026-04-21 08:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0006_rename_category_country_plant_categories'), + ] + + operations = [ + migrations.RemoveField( + model_name='plant', + name='categories', + ), + migrations.AddField( + model_name='country', + name='flag', + field=models.ImageField(blank=True, null=True, upload_to='countries/'), + ), + migrations.AddField( + model_name='plant', + name='countries', + field=models.ManyToManyField(blank=True, related_name='plants', to='plants.country'), + ), + migrations.AlterField( + model_name='country', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0008_alter_country_flag_alter_plant_countries.py b/Planteer/Planteer/plants/migrations/0008_alter_country_flag_alter_plant_countries.py new file mode 100644 index 0000000..f17adb6 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0008_alter_country_flag_alter_plant_countries.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-04-21 19:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0007_remove_plant_categories_country_flag_plant_countries_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='country', + name='flag', + field=models.ImageField(upload_to='countries/'), + ), + migrations.AlterField( + model_name='plant', + name='countries', + field=models.ManyToManyField(related_name='plants', to='plants.country'), + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0009_alter_country_flag.py b/Planteer/Planteer/plants/migrations/0009_alter_country_flag.py new file mode 100644 index 0000000..744d4c7 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0009_alter_country_flag.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-21 19:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0008_alter_country_flag_alter_plant_countries'), + ] + + operations = [ + migrations.AlterField( + model_name='country', + name='flag', + field=models.ImageField(blank=True, null=True, upload_to='countries/'), + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0010_alter_country_flag.py b/Planteer/Planteer/plants/migrations/0010_alter_country_flag.py new file mode 100644 index 0000000..5c18608 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0010_alter_country_flag.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-21 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0009_alter_country_flag'), + ] + + operations = [ + migrations.AlterField( + model_name='country', + name='flag', + field=models.ImageField(upload_to='countries/'), + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0011_remove_plant_countries_delete_country.py b/Planteer/Planteer/plants/migrations/0011_remove_plant_countries_delete_country.py new file mode 100644 index 0000000..0bd61f3 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0011_remove_plant_countries_delete_country.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-04-21 19:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0010_alter_country_flag'), + ] + + operations = [ + migrations.RemoveField( + model_name='plant', + name='countries', + ), + migrations.DeleteModel( + name='Country', + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0012_country_plant_countries.py b/Planteer/Planteer/plants/migrations/0012_country_plant_countries.py new file mode 100644 index 0000000..9369987 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0012_country_plant_countries.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-21 20:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0011_remove_plant_countries_delete_country'), + ] + + operations = [ + migrations.CreateModel( + name='Country', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('flag', models.ImageField(upload_to='countries/')), + ], + ), + migrations.AddField( + model_name='plant', + name='countries', + field=models.ManyToManyField(blank=True, to='plants.country'), + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0013_alter_country_options.py b/Planteer/Planteer/plants/migrations/0013_alter_country_options.py new file mode 100644 index 0000000..22a6ecc --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0013_alter_country_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.3 on 2026-04-26 20:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0012_country_plant_countries'), + ] + + operations = [ + migrations.AlterModelOptions( + name='country', + options={'verbose_name_plural': 'Countries'}, + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0013_alter_country_options_remove_comment_name_and_more.py b/Planteer/Planteer/plants/migrations/0013_alter_country_options_remove_comment_name_and_more.py new file mode 100644 index 0000000..c8cd2b6 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0013_alter_country_options_remove_comment_name_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-26 20:09 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0012_country_plant_countries'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='country', + options={'verbose_name_plural': 'Countries'}, + ), + migrations.AddField( + model_name='comment', + name='user', + field=models.ForeignKey(default=2, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0014_remove_comment_name_comment_user.py b/Planteer/Planteer/plants/migrations/0014_remove_comment_name_comment_user.py new file mode 100644 index 0000000..c5adde6 --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0014_remove_comment_name_comment_user.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-26 20:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0013_alter_country_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='comment', + name='name', + ), + migrations.AddField( + model_name='comment', + name='user', + field=models.ForeignKey(default=2, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0015_create_comment_table.py b/Planteer/Planteer/plants/migrations/0015_create_comment_table.py new file mode 100644 index 0000000..1d3f0db --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0015_create_comment_table.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-04-26 20:44 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0014_remove_comment_name_comment_user'), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plants.plant')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='auth.user')), + ], + ), + ] diff --git a/Planteer/Planteer/plants/migrations/0016_merge_20260427_0940.py b/Planteer/Planteer/plants/migrations/0016_merge_20260427_0940.py new file mode 100644 index 0000000..89d7c8c --- /dev/null +++ b/Planteer/Planteer/plants/migrations/0016_merge_20260427_0940.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.3 on 2026-04-27 06:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0013_alter_country_options_remove_comment_name_and_more'), + ('plants', '0015_create_comment_table'), + ] + + operations = [ + ] diff --git a/Planteer/Planteer/plants/migrations/__init__.py b/Planteer/Planteer/plants/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/plants/models.py b/Planteer/Planteer/plants/models.py new file mode 100644 index 0000000..69eae9d --- /dev/null +++ b/Planteer/Planteer/plants/models.py @@ -0,0 +1,49 @@ +from django.db import models +from django.core.validators import MinLengthValidator +from django.contrib.auth.models import User + + +class Country(models.Model): + name = models.CharField(max_length=100) + flag = models.ImageField(upload_to='countries/') + + def __str__(self) -> str: + return self.name + + class Meta: + verbose_name_plural = "Countries" + + +class Plant(models.Model): + + class Category(models.TextChoices): + FLOWER = 'flower', 'Flower' + FRUIT = 'fruit', 'Fruit' + VEGETABLE = 'vegetable', 'Vegetable' + HERB = 'herb', 'Herb' + TREE = 'tree', 'Tree' + CACTUS = 'cactus', 'Cactus' + + name = models.CharField(max_length=100, validators=[MinLengthValidator(2)]) + about = models.TextField() + used_for = models.TextField() + image = models.ImageField(upload_to='plants/') + category = models.CharField(max_length=20, choices=Category.choices) + is_edible = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + countries = models.ManyToManyField(Country, blank=True) + + + def __str__(self)->str: + return self.name + + +class Comment(models.Model): + plant=models.ForeignKey(Plant,on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + comment=models.TextField() + created_at=models.DateTimeField(auto_now_add=True) + + + def __str__(self)->str: + return f"{self.user.username} on {self.plant.name}" \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/all_plants.html b/Planteer/Planteer/plants/templates/plants/all_plants.html new file mode 100644 index 0000000..c5dfedd --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/all_plants.html @@ -0,0 +1,62 @@ +{% extends 'main/base.html' %} + +{% block title %}All Plants | Planteer{% endblock %} + +{% block content %} + + +
+
+ + + + + + + +
+ +
+ {% for plant in plants %} + {% include 'plants/includes/plant_card.html' %} + {% endfor %} +
+ +
+ {% if plants.has_previous %} + Previous + {% endif %} + + Page {{ plants.number }} of {{ plants.paginator.num_pages }} + + {% if plants.has_next %} + Next + {% endif %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/country_plants.html b/Planteer/Planteer/plants/templates/plants/country_plants.html new file mode 100644 index 0000000..1a04705 --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/country_plants.html @@ -0,0 +1,21 @@ +{% extends 'main/base.html' %} + +{% block title %}{{ country.name }} Plants | Planteer{% endblock %} + +{% block content %} + + +
+
+ {% for plant in plants %} + {% include 'plants/includes/plant_card.html' %} + {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/delete_plant.html b/Planteer/Planteer/plants/templates/plants/delete_plant.html new file mode 100644 index 0000000..367b914 --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/delete_plant.html @@ -0,0 +1,19 @@ +{% extends 'main/base.html' %} + +{% block title %}Delete Plant | Planteer{% endblock %} + +{% block content %} +
+
+ Delete +

Delete Plant

+

Are you sure you want to delete {{ plant.name }}?

+ +
+ {% csrf_token %} + + Cancel +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/includes/plant_card.html b/Planteer/Planteer/plants/templates/plants/includes/plant_card.html new file mode 100644 index 0000000..e126601 --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/includes/plant_card.html @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/new_plant.html b/Planteer/Planteer/plants/templates/plants/new_plant.html new file mode 100644 index 0000000..89703ac --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/new_plant.html @@ -0,0 +1,23 @@ +{% extends 'main/base.html' %} + +{% block title %}Add Plant | Planteer{% endblock %} + +{% block content %} + + +
+
+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/plant_detail.html b/Planteer/Planteer/plants/templates/plants/plant_detail.html new file mode 100644 index 0000000..efd43cb --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/plant_detail.html @@ -0,0 +1,153 @@ +{% extends 'main/base.html' %} + +{% block title %}{{ plant.name }} | Planteer{% endblock %} + +{% block content %} +
+
+
+ {{ plant.name }} +
+ +
+ {{ plant.category }} +
+ +

{{ plant.name }}

+ + {% if request.user.is_authenticated %} + + + {% if is_bookmarked %} + + {% else %} + + {% endif %} + + + {% endif %} + +
+

Native Countries:

+
+ {% for country in plant.countries.all %} + + {% if country.flag %} + {{ country.name }} + {% endif %} + {{ country.name }} + + {% endfor %} +
+ + +
+

Category: {{ plant.category }}

+

Is Edible: {{ plant.is_edible|yesno:"Yes,No" }}

+
+ +
+

About

+

{{ plant.about }}

+
+ +
+

Used For

+

{{ plant.used_for }}

+
+ +
+ {% if request.user.is_staff %} + Update + Delete + {% endif %} +
+
+
+
+ +
+
+ +
+ {% if request.user.is_authenticated %} + Add Comment +

Write a Comment

+
+ {% csrf_token %} +

+ +
+ {% else %} + + {% endif %} +
+ +
+ Comments +

All Comments

+
+ {% for comment in comments %} + + {% empty %} +

No reviews yet.

+ {% endfor %} +
+
+ +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/search_plants.html b/Planteer/Planteer/plants/templates/plants/search_plants.html new file mode 100644 index 0000000..31b8bf7 --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/search_plants.html @@ -0,0 +1,26 @@ +{% extends 'main/base.html' %} + +{% block title %}Search | Planteer{% endblock %} + +{% block content %} + + +
+
+ + +
+ +
+ {% for plant in plants %} + {% include 'plants/includes/plant_card.html' %} + {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/templates/plants/update_plant.html b/Planteer/Planteer/plants/templates/plants/update_plant.html new file mode 100644 index 0000000..2da2617 --- /dev/null +++ b/Planteer/Planteer/plants/templates/plants/update_plant.html @@ -0,0 +1,23 @@ +{% extends 'main/base.html' %} + +{% block title %}Update Plant | Planteer{% endblock %} + +{% block content %} + + +
+
+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/Planteer/Planteer/plants/tests.py b/Planteer/Planteer/plants/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Planteer/Planteer/plants/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Planteer/Planteer/plants/urls.py b/Planteer/Planteer/plants/urls.py new file mode 100644 index 0000000..34ab733 --- /dev/null +++ b/Planteer/Planteer/plants/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from . import views + +app_name = 'plants' + +urlpatterns = [ + path('all/', views.all_plants_view, name='all_plants'), + path('new/', views.new_plant_view, name='new_plant'), + path('search/', views.search_plants_view, name='search_plants'), + path('/detail/', views.plant_detail_view, name='plant_detail'), + path('/update/', views.update_plant_view, name='update_plant'), + path('/delete/', views.delete_plant_view, name='delete_plant'), + path('/comment/', views.add_comment_view, name='add_comment_view'), + path('country//', views.country_plants_view, name='country_plants'), + path('bookmark//', views.bookmark_view, name='bookmark'), +] \ No newline at end of file diff --git a/Planteer/Planteer/plants/views.py b/Planteer/Planteer/plants/views.py new file mode 100644 index 0000000..a899de3 --- /dev/null +++ b/Planteer/Planteer/plants/views.py @@ -0,0 +1,173 @@ + +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import HttpRequest +from .models import Plant,Comment,Country +from .forms import PlantForm +from django.contrib import messages +from accounts.models import Bookmark +from django.core.paginator import Paginator +from django.contrib.auth.decorators import user_passes_test + +def is_staff(user): + return user.is_staff + +def all_plants_view(request): + plants = Plant.objects.all().order_by('-created_at') + countries = Country.objects.all() + + category = request.GET.get('category') + is_edible = request.GET.get('is_edible') + country = request.GET.get('country') + + if category: + plants = plants.filter(category=category) + + if is_edible == 'true': + plants = plants.filter(is_edible=True) + elif is_edible == 'false': + plants = plants.filter(is_edible=False) + + if country: + plants = plants.filter(countries__id=country) + + paginator = Paginator(plants, 8) + page_number = request.GET.get('page') + plants = paginator.get_page(page_number) + + return render(request, 'plants/all_plants.html', { + 'plants': plants, + 'categories': Plant.Category.choices, + 'countries': countries + }) + + +def plant_detail_view(request, plant_id): + plant = get_object_or_404(Plant, id=plant_id) + comments = Comment.objects.filter(plant=plant).order_by("-created_at") + related_plants = Plant.objects.filter(category=plant.category).exclude(id=plant.id)[:3] + + is_bookmarked = False + + if request.user.is_authenticated: + is_bookmarked = Bookmark.objects.filter( + user=request.user, + plant=plant + ).exists() + + return render(request, 'plants/plant_detail.html', { + 'plant': plant, + 'comments': comments, + 'related_plants': related_plants, + 'is_bookmarked': is_bookmarked, + + }) + +@user_passes_test(is_staff) +def new_plant_view(request): + if request.method == 'POST': + form = PlantForm(request.POST, request.FILES) + if form.is_valid(): + plant = form.save() + return redirect('plants:plant_detail', plant_id=plant.id) + else: + form = PlantForm() + + return render(request, 'plants/new_plant.html', {'form': form}) + + +def update_plant_view(request, plant_id): + if not request.user.is_staff: + messages.warning(request, "only staff can add game", "alert-warning") + return redirect("main:home") + + plant = get_object_or_404(Plant, id=plant_id) + + if request.method == 'POST': + form = PlantForm(request.POST, request.FILES, instance=plant) + if form.is_valid(): + form.save() + return redirect('plants:plant_detail', plant_id=plant.id) + else: + form = PlantForm(instance=plant) + + return render(request, 'plants/update_plant.html', { + 'form': form, + 'plant': plant + }) + + +def delete_plant_view(request, plant_id): + if not request.user.is_staff: + messages.warning(request, "only staff can add game", "alert-warning") + return redirect("main:home") + + plant = get_object_or_404(Plant, id=plant_id) + + if request.method == 'POST': + plant.delete() + return redirect('plants:all_plants') + + return render(request, 'plants/delete_plant.html', {'plant': plant}) + + +def search_plants_view(request): + query = request.GET.get('q', '') + plants = [] + + if query: + plants = Plant.objects.filter(name__istartswith=query) + + return render(request, 'plants/search_plants.html', { + 'plants': plants, + 'query': query + }) + + +def add_comment_view(request: HttpRequest, plant_id): + if not request.user.is_authenticated: + messages.error(request, "Only registered user can add comment") + return redirect("accounts:sign_in") + + if request.method == "POST": + plant_object = Plant.objects.get(pk=plant_id) + new_comment = Comment( + plant=plant_object, + user=request.user, + comment=request.POST["comment"] + ) + new_comment.save() + messages.success(request, "Added Comment Successfully", "alert-success") + return redirect("plants:plant_detail", plant_id=plant_id) + + +def country_plants_view(request, country_id): + country = get_object_or_404(Country, id=country_id) + plants = Plant.objects.filter(countries=country).distinct() + context = { + 'country': country, + 'plants': plants, + } + return render(request, 'plants/country_plants.html', context) + + +def bookmark_view(request: HttpRequest, plant_id): + if not request.user.is_authenticated: + messages.error(request, "Only registered users can add bookmarks") + return redirect("accounts:sign_in") + try: + plant = Plant.objects.get(pk=plant_id) + + bookmark = Bookmark.objects.filter(plant=plant, user=request.user).first() + + if not bookmark: + Bookmark.objects.create(user=request.user, plant=plant) + messages.success(request, "Bookmark added") + else: + bookmark.delete() + messages.warning(request, "Bookmark removed") + + except Exception as e: + print(e) + messages.error(request, "Something went wrong") + + return redirect("plants:plant_detail", plant_id=plant_id) \ No newline at end of file