diff --git a/index.html b/index.html index 27a99f796e..e9194468ee 100644 --- a/index.html +++ b/index.html @@ -5,8 +5,12 @@ + + + + - Hello Rigo + Expedition
diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py deleted file mode 100644 index 88964176f1..0000000000 --- a/migrations/versions/0763d677d453_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""empty message - -Revision ID: 0763d677d453 -Revises: -Create Date: 2025-02-25 14:47:16.337069 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '0763d677d453' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=120), nullable=False), - sa.Column('password', sa.String(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('user') - # ### end Alembic commands ### diff --git a/migrations/versions/a641ec06e1ec_.py b/migrations/versions/a641ec06e1ec_.py new file mode 100644 index 0000000000..f0e4142b50 --- /dev/null +++ b/migrations/versions/a641ec06e1ec_.py @@ -0,0 +1,119 @@ +"""empty message + +Revision ID: a641ec06e1ec +Revises: +Create Date: 2026-04-08 18:19:57.987352 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a641ec06e1ec' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('trip', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=30), nullable=False), + sa.Column('destination', sa.String(length=50), nullable=False), + sa.Column('state', sa.Enum('FINISHED', 'ONGOING', 'PLANNING', name='statetypes'), nullable=False), + sa.Column('starting_date', sa.Date(), nullable=False), + sa.Column('ending_date', sa.Date(), nullable=False), + sa.Column('budget', sa.Float(), nullable=False), + sa.Column('notes', sa.String(length=150), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=20), nullable=False), + sa.Column('last_name', sa.String(length=50), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('chat', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=True), + sa.Column('trip_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['trip_id'], ['trip.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('document', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('url', sa.String(length=250), nullable=False), + sa.Column('trip_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['trip_id'], ['trip.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('expense', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('description', sa.String(length=100), nullable=False), + sa.Column('trip_id', sa.Integer(), nullable=False), + sa.Column('payer_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['payer_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['trip_id'], ['trip.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('itinerary', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=30), nullable=False), + sa.Column('destination', sa.String(length=50), nullable=False), + sa.Column('hour', sa.Time(), nullable=False), + sa.Column('starting_date', sa.Date(), nullable=False), + sa.Column('notes', sa.String(length=150), nullable=False), + sa.Column('trip_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['trip_id'], ['trip.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('traveler', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('trip_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['trip_id'], ['trip.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'trip_id') + ) + op.create_table('debt', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('debtor_id', sa.Integer(), nullable=False), + sa.Column('creditor_id', sa.Integer(), nullable=False), + sa.Column('expense_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['creditor_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['debtor_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['expense_id'], ['expense.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('message', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('content', sa.String(length=500), nullable=False), + sa.Column('date_time', sa.DateTime(), nullable=False), + sa.Column('chat_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['chat_id'], ['chat.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('message') + op.drop_table('debt') + op.drop_table('traveler') + op.drop_table('itinerary') + op.drop_table('expense') + op.drop_table('document') + op.drop_table('chat') + op.drop_table('user') + op.drop_table('trip') + # ### end Alembic commands ### diff --git a/migrations/versions/a9c11e29682c_.py b/migrations/versions/a9c11e29682c_.py new file mode 100644 index 0000000000..9df1a222be --- /dev/null +++ b/migrations/versions/a9c11e29682c_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: a9c11e29682c +Revises: a641ec06e1ec +Create Date: 2026-04-13 18:29:47.379931 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a9c11e29682c' +down_revision = 'a641ec06e1ec' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('last_name', + existing_type=sa.VARCHAR(length=50), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('last_name', + existing_type=sa.VARCHAR(length=50), + nullable=False) + + # ### end Alembic commands ### diff --git a/package-lock.json b/package-lock.json index 8d43d98ab7..398b3c017c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -877,19 +877,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -997,14 +984,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/node": { - "version": "16.11.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz", - "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -1307,14 +1286,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3915,29 +3886,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -4074,35 +4022,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4944,18 +4863,6 @@ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, - "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -5044,14 +4951,6 @@ "@babel/types": "^7.20.7" } }, - "@types/node": { - "version": "16.11.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz", - "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==", - "dev": true, - "optional": true, - "peer": true - }, "@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -5252,14 +5151,6 @@ "update-browserslist-db": "^1.1.1" } }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6982,28 +6873,6 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7094,30 +6963,6 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, - "terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/src/api/models.py b/src/api/models.py index da515f6a1a..50851fbafd 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,19 +1,219 @@ +import enum from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Boolean, Enum, Date, Integer, ForeignKey, Float, Time, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import date, time, datetime +from werkzeug.security import check_password_hash, generate_password_hash db = SQLAlchemy() +# --- ENUMS --- + + +class StateTypes(enum.Enum): + FINISHED = "finished" + ONGOING = "ongoing" + PLANNING = "planning" + + +class CategoryTypes(enum.Enum): + TRANSPORT = "transport" + LODGING = "lodging" + FOOD = "food" + ACTIVITIES = "activities" + OTHERS = "others" + +# --- MODELS --- + + class User(db.Model): + __tablename__ = 'user' id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(20), nullable=False) + last_name: Mapped[str] = mapped_column(String(50), nullable=True) + email: Mapped[str] = mapped_column( + String(120), unique=True, nullable=False) password: Mapped[str] = mapped_column(nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False) + + # Relationships + travelers = relationship("Traveler", back_populates="users") + expenses_paid = relationship("Expense", back_populates="payers") + messages = relationship("Message", back_populates="authors") + debts_owed = relationship( + "Debt", foreign_keys="[Debt.debtor_id]", back_populates="debtors") + debts_to_receive = relationship( + "Debt", foreign_keys="[Debt.creditor_id]", back_populates="creditors") + + def set_password(self, password: str) -> None: + self.password = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password, password) + + def serialize(self): + return { + "id": self.id, + "name": self.name, + "last_name": self.last_name, + "email": self.email + } + + +class Trip(db.Model): + __tablename__ = 'trip' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(30), nullable=False) + destination: Mapped[str] = mapped_column(String(50), nullable=False) + state: Mapped[StateTypes] = mapped_column( + Enum(StateTypes), nullable=False) + starting_date: Mapped[date] = mapped_column(Date(), nullable=False) + ending_date: Mapped[date] = mapped_column(Date(), nullable=False) + budget: Mapped[float] = mapped_column(Float, nullable=False) + notes: Mapped[str] = mapped_column(String(150), nullable=True) + + # Relationships + travelers = relationship("Traveler", back_populates="trips") + itineraries = relationship("Itinerary", back_populates="trips") + expenses = relationship("Expense", back_populates="trips") + documents = relationship("Document", back_populates="trips") + chats = relationship("Chat", back_populates="trips", uselist=False) + + def serialize(self): + return { + "id": self.id, + "title": self.title, + "destination": self.destination, + "state": self.state.value, + "starting_date": str(self.starting_date), + "ending_date": str(self.ending_date), + "budget": self.budget, + "notes": self.notes + } + + def serialize_common_trips(self): + return { + "id": self.id, + "title": self.title, + "state": self.state.value, + "starting_date": str(self.starting_date), + "ending_date": str(self.ending_date), + } + + + +class Traveler(db.Model): + __tablename__ = 'traveler' + user_id: Mapped[int] = mapped_column(ForeignKey( + "user.id", ondelete="CASCADE"), primary_key=True) + trip_id: Mapped[int] = mapped_column(ForeignKey( + "trip.id", ondelete="CASCADE"), primary_key=True) + + users = relationship("User", back_populates="travelers") + trips = relationship("Trip", back_populates="travelers") + + def serialize(self): + return { + "user_id": self.user_id, + "trip_id": self.trip_id + } + + def serialize_trip(self): + return { + "trip_id": self.trip_id + } + + def serialize_user(self): + return { + "user_id": self.user_id + } +class Itinerary(db.Model): + __tablename__ = 'itinerary' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(30), nullable=False) + destination: Mapped[str] = mapped_column(String(50), nullable=False) + hour: Mapped[time] = mapped_column(Time, nullable=False) + starting_date: Mapped[date] = mapped_column(Date(), nullable=False) + notes: Mapped[str] = mapped_column(String(150), nullable=False) + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + + trips = relationship("Trip", back_populates="itineraries") + def serialize(self): return { "id": self.id, - "email": self.email, - # do not serialize the password, its a security breach - } \ No newline at end of file + "title": self.title, + "hour": str(self.hour), + "trip_id": self.trip_id + } + + +class Expense(db.Model): + __tablename__ = 'expense' + id: Mapped[int] = mapped_column(primary_key=True) + amount: Mapped[float] = mapped_column(Float, nullable=False) + description: Mapped[str] = mapped_column(String(100), nullable=False) + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + payer_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + trips = relationship("Trip", back_populates="expenses") + payers = relationship("User", back_populates="expenses_paid") + debts = relationship("Debt", back_populates="expenses") + + def serialize(self): + return {"id": self.id, "amount": self.amount, "description": self.description} + + +class Debt(db.Model): + __tablename__ = 'debt' + id: Mapped[int] = mapped_column(primary_key=True) + amount: Mapped[float] = mapped_column(Float, nullable=False) + debtor_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + creditor_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + expense_id: Mapped[int] = mapped_column(ForeignKey("expense.id")) + + expenses = relationship("Expense", back_populates="debts") + debtors = relationship("User", foreign_keys=[ + debtor_id], back_populates="debts_owed") + creditors = relationship("User", foreign_keys=[ + creditor_id], back_populates="debts_to_receive") + + +class Document(db.Model): + __tablename__ = 'document' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(50), nullable=False) + url: Mapped[str] = mapped_column(String(250), nullable=False) + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + + trips = relationship("Trip", back_populates="documents") + + +class Chat(db.Model): + __tablename__ = 'chat' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column( + String(50), nullable=True) # <-- Título añadido + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + + trips = relationship("Trip", back_populates="chats") + messages = relationship("Message", back_populates="chats") + + +class Message(db.Model): + __tablename__ = 'message' + id: Mapped[int] = mapped_column(primary_key=True) + content: Mapped[str] = mapped_column(String(500), nullable=False) + date_time: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + chat_id: Mapped[int] = mapped_column( + ForeignKey("chat.id", ondelete="CASCADE")) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + chats = relationship("Chat", back_populates="messages") + authors = relationship("User", back_populates="messages") diff --git a/src/api/routes.py b/src/api/routes.py index 029589a3a1..6b877dc23b 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,22 +1,118 @@ """ This module takes care of starting the API Server, Loading the DB and Adding the endpoints """ -from flask import Flask, request, jsonify, url_for, Blueprint -from api.models import db, User -from api.utils import generate_sitemap, APIException -from flask_cors import CORS +from flask import Blueprint, jsonify, request +from flask_jwt_extended import ( + create_access_token, + get_jwt_identity, + jwt_required +) -api = Blueprint('api', __name__) +from api.models import db, User, Trip, Traveler, Itinerary, Expense, Debt, Document, Chat, Message +from api.utils import APIException -# Allow CORS requests to this API -CORS(api) +api = Blueprint("api", __name__) -@api.route('/hello', methods=['POST', 'GET']) -def handle_hello(): - response_body = { - "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request" - } +def get_json_payload(): + return request.get_json(silent=True) or {} - return jsonify(response_body), 200 + +def build_auth_response(user, status_code, message): + access_token = create_access_token(identity=str(user.id)) + return jsonify({ + "message": message, + "access_token": access_token, + "user": user.serialize() + }), status_code + + +def get_current_user(): + identity = get_jwt_identity() + if identity is None: + raise APIException("Missing user identity in token", status_code=401) + + try: + user_id = int(identity) + except (TypeError, ValueError) as error: + raise APIException("Invalid token identity", + status_code=401) from error + + user = db.session.get(User, user_id) + if user is None: + raise APIException("Authenticated user was not found", status_code=404) + + return user + + +def validate_credentials(payload, require_name=False): + name = payload.get("name", "").strip() + email = payload.get("email", "").strip().lower() + password = payload.get("password", "") + + if require_name and len(name) < 2: + raise APIException( + "Name must contain at least 2 characters", status_code=400) + + if "@" not in email: + raise APIException( + "Please provide a valid email address", status_code=400) + + if len(password) < 6: + raise APIException( + "Password must contain at least 6 characters", status_code=400) + + return name, email, password + + +@api.route("/login", methods=["POST"]) +@api.route("/signin", methods=["POST"]) +def sign_in(): + data = get_json_payload() + _, email, password = validate_credentials(data) + + user = User.query.filter_by(email=email).one_or_none() + if user is None or not user.check_password(password): + raise APIException("Wrong email or password", status_code=401) + + return build_auth_response(user, 200, "Sign in successful") + + +@api.route("/sign-up", methods=["POST"]) +@api.route("/signup", methods=["POST"]) +@api.route("/register", methods=["POST"]) +def sign_up(): + data = get_json_payload() + name, email, password = validate_credentials(data, require_name=True) + + existing_user = User.query.filter_by(email=email).one_or_none() + if existing_user is not None: + raise APIException( + "Ya existe un usuario con registrado con este correo", status_code=409) + + new_user = User( + email=email, + name=name + ) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + return build_auth_response(new_user, 201, "Usuario creado correctamente") + + + +@api.route("/travels", methods=["GET"]) +@api.route("/trips", methods=["GET"]) +@jwt_required +def travels(): + user = get_current_user() + trips_by_traveler = Traveler.query.filter_by(user_id=user.id).all() + trip_ids = [t.trip_id for t in trips_by_traveler] + trips = Trip.query.filter(Trip.id.in_(trip_ids)) + + return jsonify({ + "viajes": [trip.serialize_common_trips() for trip in trips] + }), 200 diff --git a/src/front/assets/img/EXPEDITION-LOGO.png b/src/front/assets/img/EXPEDITION-LOGO.png new file mode 100644 index 0000000000..0de4fdf1a4 Binary files /dev/null and b/src/front/assets/img/EXPEDITION-LOGO.png differ diff --git a/src/front/assets/img/fondo-landing.jpg b/src/front/assets/img/fondo-landing.jpg new file mode 100644 index 0000000000..324f356358 Binary files /dev/null and b/src/front/assets/img/fondo-landing.jpg differ diff --git a/src/front/assets/img/fondo-login.jpg b/src/front/assets/img/fondo-login.jpg new file mode 100644 index 0000000000..e4474e9483 Binary files /dev/null and b/src/front/assets/img/fondo-login.jpg differ diff --git a/src/front/assets/img/fondo-mytrips.jpg b/src/front/assets/img/fondo-mytrips.jpg new file mode 100644 index 0000000000..99b8997493 Binary files /dev/null and b/src/front/assets/img/fondo-mytrips.jpg differ diff --git a/src/front/components/Features.jsx b/src/front/components/Features.jsx new file mode 100644 index 0000000000..e966b960aa --- /dev/null +++ b/src/front/components/Features.jsx @@ -0,0 +1,24 @@ +import React from "react"; + +export const Features = () => { + return ( +
+

Todo lo que necesitas

+
+
💰
+

Gestión de Gastos

+

Divide cuentas y liquida deudas fácilmente (Model: Expense/Debt).

+
+
+
📍
+

Itinerarios Vivos

+

Planifica cada parada con tu grupo en tiempo real (Model: Itinerary).

+
+
+
💬
+

Chat Grupal

+

Toda la comunicación en un solo lugar (Model: Chat/Message).

+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/components/Hero.jsx b/src/front/components/Hero.jsx new file mode 100644 index 0000000000..3588dd8803 --- /dev/null +++ b/src/front/components/Hero.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { Stats } from "./Stats"; + +export const Hero = () => { + const navigate = useNavigate(); + + return ( +
+
+

Viaja en grupo
sin complicaciones

+ + {/* Texto actualizado al diseño */} +

+ Organiza destinos, gastos y decisiones con tus amigos en un solo lugar. +

+ +
+ {/* Botones actualizados */} + + +
+
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 30d43a2636..615237f6db 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,19 +1,87 @@ -import { Link } from "react-router-dom"; +import React, { useState } from "react"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import "../styles/Navbar.css"; + +// 1. IMPORTAMOS TU LOGO AQUÍ +// IMPORTANTE: Cambia "tu-logo.png" por el nombre exacto de tu archivo y asegúrate de que esté en esa carpeta. +import logoExpedition from "../assets/img/EXPEDITION-LOGO.png"; export const Navbar = () => { + const [menuOpen, setMenuOpen] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + + // MAGIA: Detectamos si el usuario está en la Landing Page (Inicio público) + const isLandingPage = location.pathname === "/"; + // MAGIA NUEVA: Detectamos si está en el Login + const isLoginPage = location.pathname === "/login"; + + // Aquí agregamos la clase dinámica en el nav para que cambie a modo login + return ( + + ); }; \ No newline at end of file diff --git a/src/front/components/Stats.jsx b/src/front/components/Stats.jsx new file mode 100644 index 0000000000..5a80fe5d9b --- /dev/null +++ b/src/front/components/Stats.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +export const Stats = () => { + return ( +
+
+
+ 1M+ + Viajeros +
+
+ 500K + Destinos +
+
+ {/* El texto extra que aparece abajo en tu diseño */} +

+ Expande tus horizontes, nosotros hacemos el resto +

+
+ ); +}; \ No newline at end of file diff --git a/src/front/index.css b/src/front/index.css index e69de29bb2..a6bc2543e8 100644 --- a/src/front/index.css +++ b/src/front/index.css @@ -0,0 +1,11 @@ +:root { + --brand-orange: #FF7A59; + --brand-navy: #1E3A5F; + --brand-teal: #2EC4B6; + --bg-light: #F8FAFC; +} + +body { + font-family: 'Poppins', sans-serif; + color: var(--brand-navy); +} \ No newline at end of file diff --git a/src/front/main.jsx b/src/front/main.jsx index a5a3c781dc..433d93defb 100644 --- a/src/front/main.jsx +++ b/src/front/main.jsx @@ -1,29 +1,24 @@ +// src/front/main.jsx import React from 'react' import ReactDOM from 'react-dom/client' -import './index.css' // Global styles for your application -import { RouterProvider } from "react-router-dom"; // Import RouterProvider to use the router -import { router } from "./routes"; // Import the router configuration -import { StoreProvider } from './hooks/useGlobalReducer'; // Import the StoreProvider for global state management +import './index.css' +import { RouterProvider } from "react-router-dom"; +import { router } from "./routes"; +import { StoreProvider } from './hooks/useGlobalReducer'; import { BackendURL } from './components/BackendURL'; const Main = () => { - - if(! import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL == "") return ( - - - - ); + // Si no hay URL de backend, mostramos el aviso + if(!import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL == "") { + return ; + } + return ( - - {/* Provide global state to all components */} - - {/* Set up routing for the application */} - - - - + + {/* RouterProvider DEBE ser el único que maneje la vista */} + + ); } -// Render the Main component into the root DOM element. -ReactDOM.createRoot(document.getElementById('root')).render(
) +ReactDOM.createRoot(document.getElementById('root')).render(
) \ No newline at end of file diff --git a/src/front/pages/AuthPage.jsx b/src/front/pages/AuthPage.jsx new file mode 100644 index 0000000000..58433d3894 --- /dev/null +++ b/src/front/pages/AuthPage.jsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from "react"; +import { useLocation } from "react-router-dom"; // <-- Importamos useLocation +import "../styles/AuthPage.css"; + +export const AuthPage = () => { + const location = useLocation(); + const [isLogin, setIsLogin] = useState(true); + + // Este efecto vigila de dónde viene el usuario para abrir la pestaña correcta + useEffect(() => { + if (location.state?.tab === "register") { + setIsLogin(false); + } else if (location.state?.tab === "login") { + setIsLogin(true); + } + }, [location]); // Se ejecuta cada vez que cambia la navegación + + return ( +
+
+ {/* PESTAÑAS (TABS) */} +
+ + +
+ + {/* CONTENIDO DEL FORMULARIO */} +
+

{isLogin ? "Bienvenido de nuevo" : "Crea tu cuenta"}

+

+ {isLogin + ? "Gestiona tus aventuras compartidas con facilidad." + : "Únete a Expedition y empieza a planificar con tus amigos."} +

+ +
e.preventDefault()}> + {/* Campo Nombre (Solo aparece si es Registro) */} + {!isLogin && ( +
+ +
+ + +
+
+ )} + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + {isLogin && ( + + )} + + +
+ + {/* REDES SOCIALES */} +
+ O CONTINÚA CON +
+ +
+ + +
+
+
+ + {/* FOOTER DEL COMPONENTE */} +
+ {isLogin ? "¿Aún no tienes cuenta? " : "¿Ya tienes una cuenta? "} + setIsLogin(!isLogin)}> + {isLogin ? "Únete a la comunidad" : "Inicia sesión"} + +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/LandingPage.jsx b/src/front/pages/LandingPage.jsx new file mode 100644 index 0000000000..0f75822b71 --- /dev/null +++ b/src/front/pages/LandingPage.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Hero } from "../components/Hero"; +import { Stats } from "../components/Stats"; +import { Features } from "../components/Features"; +import "../styles/LandingPage.css"; // + +export const LandingPage = () => { + return ( +
+ {/* Aquí llamamos a los componentes */} + + + + {/* CALL TO ACTION FINAL (Este es pequeñito, lo podemos dejar aquí o separarlo luego) */} +
+

¿Listo para tu próxima expedición?

+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Layout.jsx b/src/front/pages/Layout.jsx index 9bfa31325c..300915cb2c 100644 --- a/src/front/pages/Layout.jsx +++ b/src/front/pages/Layout.jsx @@ -1,15 +1,17 @@ -import { Outlet } from "react-router-dom/dist" -import ScrollToTop from "../components/ScrollToTop" -import { Navbar } from "../components/Navbar" -import { Footer } from "../components/Footer" +import React from "react"; +import { Outlet } from "react-router-dom"; // Outlet es donde se cargan las páginas como LandingPage o Home +import { Navbar } from "../components/Navbar"; // Verifica que la "N" coincida con tu archivo +import { Footer } from "../components/Footer"; +import ScrollToTop from "../components/ScrollToTop"; -// Base component that maintains the navbar and footer throughout the page and the scroll to top functionality. export const Layout = () => { return ( - - - -