From ce44a09daf8c77192d0bf3ce8eb40639658d4807 Mon Sep 17 00:00:00 2001 From: Lucrecia Parodi Date: Sun, 16 Nov 2025 21:21:24 +0000 Subject: [PATCH 1/2] sistema de reservas --- Pipfile | 8 +- Pipfile.lock | 175 ++- migrations/versions/377f79565771_.py | 76 ++ migrations/versions/4605fa1cbe47_.py | 206 ++++ migrations/versions/5c9f71906a93_.py | 46 + migrations/versions/c92a78fd1a22_.py | 67 ++ package-lock.json | 56 +- requirements.txt | 1 + src/api/commands.py | 11 +- src/api/email_service.py | 480 ++++++++ src/api/models.py | 524 ++++++++- src/api/routes.py | 1627 +++++++++++++++++++++++++- src/api/seed.py | 215 ++++ src/app.py | 45 +- src/front/components/Footer.jsx | 299 ++++- src/front/components/Navbar.jsx | 172 ++- src/front/components/SearchBar.jsx | 226 ++++ src/front/hooks/useGlobalReducer.jsx | 28 +- src/front/pages/Cart.jsx | 223 ++++ src/front/pages/Checkout.jsx | 361 ++++++ src/front/pages/Confirmation.jsx | 270 +++++ src/front/pages/ExperienceDetail.jsx | 298 +++++ src/front/pages/Home.jsx | 267 ++++- src/front/pages/RoomDetail.jsx | 341 ++++++ src/front/pages/SearchResults.jsx | 166 +++ src/front/routes.jsx | 50 +- src/front/store.js | 152 ++- 27 files changed, 6176 insertions(+), 214 deletions(-) create mode 100644 migrations/versions/377f79565771_.py create mode 100644 migrations/versions/4605fa1cbe47_.py create mode 100644 migrations/versions/5c9f71906a93_.py create mode 100644 migrations/versions/c92a78fd1a22_.py create mode 100644 src/api/email_service.py create mode 100644 src/api/seed.py create mode 100644 src/front/components/SearchBar.jsx create mode 100644 src/front/pages/Cart.jsx create mode 100644 src/front/pages/Checkout.jsx create mode 100644 src/front/pages/Confirmation.jsx create mode 100644 src/front/pages/ExperienceDetail.jsx create mode 100644 src/front/pages/RoomDetail.jsx create mode 100644 src/front/pages/SearchResults.jsx diff --git a/Pipfile b/Pipfile index 4d377014ae..9ce9d4e386 100644 --- a/Pipfile +++ b/Pipfile @@ -11,15 +11,17 @@ flask-sqlalchemy = "*" flask-migrate = "*" flask-swagger = "*" psycopg2-binary = "*" -python-dotenv = "*" -flask-cors = "*" gunicorn = "*" cloudinary = "*" flask-admin = "==2.0.0" typing-extensions = "*" -flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +flask-mail = "*" +flask-jwt-extended = "*" +stripe = "*" +python-dotenv = "*" +flask-cors = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index d9e474e972..468f9d7bd9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50" + "sha256": "07d2109e62459b2f49554989d7d56aa9d3a1230637990c73f7f7eea57de9a708" }, "pipfile-spec": 6, "requires": { @@ -34,19 +34,138 @@ }, "certifi": { "hashes": [ - "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", - "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", + "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" ], "markers": "python_version >= '3.7'", - "version": "==2025.10.5" + "version": "==2025.11.12" + }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", + "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.3.1" }, "cloudinary": { "hashes": [ @@ -85,12 +204,21 @@ }, "flask-jwt-extended": { "hashes": [ - "sha256:63a28fc9731bcc6c4b8815b6f954b5904caa534fc2ae9b93b1d3ef12930dca95", - "sha256:9215d05a9413d3855764bcd67035e75819d23af2fafb6b55197eb5a3313fdfb2" + "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978", + "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==4.6.0" + "markers": "python_version >= '3.9' and python_version < '4'", + "version": "==4.7.1" + }, + "flask-mail": { + "hashes": [ + "sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d", + "sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==0.10.0" }, "flask-migrate": { "hashes": [ @@ -187,6 +315,14 @@ "markers": "python_version >= '3.7'", "version": "==23.0.0" }, + "idna": { + "hashes": [ + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + ], + "markers": "python_version >= '3.8'", + "version": "==3.11" + }, "itsdangerous": { "hashes": [ "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", @@ -484,6 +620,14 @@ "markers": "python_version >= '3.8'", "version": "==6.0.3" }, + "requests": { + "hashes": [ + "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", + "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" + ], + "markers": "python_version >= '3.9'", + "version": "==2.32.5" + }, "six": { "hashes": [ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", @@ -556,6 +700,15 @@ "markers": "python_version >= '3.7'", "version": "==2.0.44" }, + "stripe": { + "hashes": [ + "sha256:e7d18bd44bab1812bc4e9e75da4ceacedd587f71737bb9193955edf420605f88", + "sha256:ed6ad1c27725e1e32a336fa9d835cfa8f0bd6dafcc98b3ab7bc7e8791390453b" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==13.2.0" + }, "typing-extensions": { "hashes": [ "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", diff --git a/migrations/versions/377f79565771_.py b/migrations/versions/377f79565771_.py new file mode 100644 index 0000000000..f85dc04226 --- /dev/null +++ b/migrations/versions/377f79565771_.py @@ -0,0 +1,76 @@ +"""empty message + +Revision ID: 377f79565771 +Revises: 5c9f71906a93 +Create Date: 2025-11-16 15:20:11.850371 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '377f79565771' +down_revision = '5c9f71906a93' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('email_type', sa.String(length=50), nullable=False), + sa.Column('recipient_email', sa.String(length=120), nullable=False), + sa.Column('subject', sa.String(length=200), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'SENT', 'FAILED', name='emailstatus'), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('bookings', schema=None) as batch_op: + batch_op.add_column(sa.Column('confirmation_number', sa.String(length=20), nullable=False)) + batch_op.create_index(batch_op.f('ix_bookings_confirmation_number'), ['confirmation_number'], unique=True) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('verification_token', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('password_reset_token', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('password_reset_expires', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('is_guest', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('last_login', sa.DateTime(), nullable=True)) + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=255), + nullable=True) + batch_op.create_unique_constraint(None, ['verification_token']) + batch_op.create_unique_constraint(None, ['password_reset_token']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_constraint(None, type_='unique') + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=255), + nullable=False) + batch_op.drop_column('last_login') + batch_op.drop_column('is_guest') + batch_op.drop_column('password_reset_expires') + batch_op.drop_column('password_reset_token') + batch_op.drop_column('verification_token_expires') + batch_op.drop_column('verification_token') + batch_op.drop_column('email_verified') + + with op.batch_alter_table('bookings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_bookings_confirmation_number')) + batch_op.drop_column('confirmation_number') + + op.drop_table('email_logs') + # ### end Alembic commands ### diff --git a/migrations/versions/4605fa1cbe47_.py b/migrations/versions/4605fa1cbe47_.py new file mode 100644 index 0000000000..31917ea96b --- /dev/null +++ b/migrations/versions/4605fa1cbe47_.py @@ -0,0 +1,206 @@ +"""empty message + +Revision ID: 4605fa1cbe47 +Revises: 0763d677d453 +Create Date: 2025-11-16 15:09:18.218494 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4605fa1cbe47' +down_revision = '0763d677d453' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('experiences', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('max_capacity', sa.Integer(), nullable=False), + sa.Column('duration_hours', sa.Integer(), nullable=True), + sa.Column('image_url', sa.String(length=500), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('extras', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('type', sa.Enum('PER_BOOKING', 'PER_GUEST', name='extratype'), nullable=False), + sa.Column('image_url', sa.String(length=500), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('rooms', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('capacity', sa.Integer(), nullable=False), + sa.Column('price_per_night', sa.Float(), nullable=False), + sa.Column('image_url', sa.String(length=500), nullable=True), + sa.Column('amenities', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('role', sa.Enum('USER', 'ADMIN', name='userrole'), nullable=False), + sa.Column('name', sa.String(length=100), nullable=True), + sa.Column('phone', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True) + + op.create_table('experience_availability', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('experience_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('available_spots', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('experience_id', 'date', name='_experience_date_uc') + ) + with op.batch_alter_table('experience_availability', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_experience_availability_date'), ['date'], unique=False) + + op.create_table('experience_schedules', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('experience_id', sa.Integer(), nullable=False), + sa.Column('day_of_week', sa.Enum('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY', name='dayofweek'), nullable=False), + sa.Column('start_time', sa.Time(), nullable=False), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('packages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('image_url', sa.String(length=500), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('room_id', sa.Integer(), nullable=True), + sa.Column('experience_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('room_availability', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('room_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('is_available', sa.Boolean(), nullable=False), + sa.Column('reason', sa.String(length=200), nullable=True), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('room_id', 'date', name='_room_date_uc') + ) + with op.batch_alter_table('room_availability', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_room_availability_date'), ['date'], unique=False) + + op.create_table('bookings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('experience_id', sa.Integer(), nullable=True), + sa.Column('package_id', sa.Integer(), nullable=True), + sa.Column('experience_date', sa.Date(), nullable=True), + sa.Column('check_in', sa.Date(), nullable=True), + sa.Column('check_out', sa.Date(), nullable=True), + sa.Column('number_of_guests', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED', name='bookingstatus'), nullable=False), + sa.Column('total_price', sa.Float(), nullable=False), + sa.Column('stripe_payment_intent_id', sa.String(length=200), nullable=True), + sa.Column('stripe_payment_status', sa.String(length=50), nullable=True), + sa.Column('payment_status', sa.Enum('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'REFUNDED', name='paymentstatus'), nullable=False), + sa.Column('special_requests', sa.Text(), nullable=True), + sa.Column('admin_notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), + sa.ForeignKeyConstraint(['package_id'], ['packages.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('package_extras', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('package_id', sa.Integer(), nullable=False), + sa.Column('extra_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['extra_id'], ['extras.id'], ), + sa.ForeignKeyConstraint(['package_id'], ['packages.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('booking_extras', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('extra_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.ForeignKeyConstraint(['extra_id'], ['extras.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('booking_rooms', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('room_id', sa.Integer(), nullable=False), + sa.Column('check_in', sa.Date(), nullable=False), + sa.Column('check_out', sa.Date(), nullable=False), + sa.Column('nights', sa.Integer(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('user') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('email', sa.VARCHAR(length=120), autoincrement=False, nullable=False), + sa.Column('password', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('user_pkey')), + sa.UniqueConstraint('email', name=op.f('user_email_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.drop_table('booking_rooms') + op.drop_table('booking_extras') + op.drop_table('package_extras') + op.drop_table('bookings') + with op.batch_alter_table('room_availability', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_room_availability_date')) + + op.drop_table('room_availability') + op.drop_table('packages') + op.drop_table('experience_schedules') + with op.batch_alter_table('experience_availability', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_experience_availability_date')) + + op.drop_table('experience_availability') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_email')) + + op.drop_table('users') + op.drop_table('rooms') + op.drop_table('extras') + op.drop_table('experiences') + # ### end Alembic commands ### diff --git a/migrations/versions/5c9f71906a93_.py b/migrations/versions/5c9f71906a93_.py new file mode 100644 index 0000000000..a4a3abe400 --- /dev/null +++ b/migrations/versions/5c9f71906a93_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 5c9f71906a93 +Revises: 4605fa1cbe47 +Create Date: 2025-11-16 15:14:33.202794 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5c9f71906a93' +down_revision = '4605fa1cbe47' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('bookings', schema=None) as batch_op: + batch_op.add_column(sa.Column('experience_time', sa.Time(), nullable=True)) + batch_op.add_column(sa.Column('check_in_time', sa.Time(), nullable=True)) + batch_op.add_column(sa.Column('check_out_time', sa.Time(), nullable=True)) + batch_op.add_column(sa.Column('cart_expires_at', sa.DateTime(), nullable=True)) + + with op.batch_alter_table('rooms', schema=None) as batch_op: + batch_op.add_column(sa.Column('check_in_time', sa.Time(), nullable=False)) + batch_op.add_column(sa.Column('check_out_time', sa.Time(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('rooms', schema=None) as batch_op: + batch_op.drop_column('check_out_time') + batch_op.drop_column('check_in_time') + + with op.batch_alter_table('bookings', schema=None) as batch_op: + batch_op.drop_column('cart_expires_at') + batch_op.drop_column('check_out_time') + batch_op.drop_column('check_in_time') + batch_op.drop_column('experience_time') + + # ### end Alembic commands ### diff --git a/migrations/versions/c92a78fd1a22_.py b/migrations/versions/c92a78fd1a22_.py new file mode 100644 index 0000000000..c2bcb69e49 --- /dev/null +++ b/migrations/versions/c92a78fd1a22_.py @@ -0,0 +1,67 @@ +"""empty message + +Revision ID: c92a78fd1a22 +Revises: 377f79565771 +Create Date: 2025-11-16 20:59:23.898397 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c92a78fd1a22' +down_revision = '377f79565771' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('booking_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('experience_id', sa.Integer(), nullable=True), + sa.Column('date', sa.Date(), nullable=True), + sa.Column('available_spots', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('item_type', sa.String(length=50), nullable=False), + sa.Column('room_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('image_url', sa.String(length=500), nullable=True), + sa.Column('guests', sa.Integer(), nullable=True), + sa.Column('check_in', sa.Date(), nullable=True), + sa.Column('check_out', sa.Date(), nullable=True), + sa.Column('nights', sa.Integer(), nullable=True), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.Column('subtotal', sa.Float(), nullable=False), + sa.Column('extras', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('experience_id', 'date', name='_experience_date_uc') + ) + with op.batch_alter_table('experience_availability', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_experience_availability_date')) + + op.drop_table('experience_availability') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('experience_availability', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('experience_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('date', sa.DATE(), autoincrement=False, nullable=False), + sa.Column('available_spots', sa.INTEGER(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], name=op.f('experience_availability_experience_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('experience_availability_pkey')), + sa.UniqueConstraint('experience_id', 'date', name=op.f('_experience_date_uc'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + with op.batch_alter_table('experience_availability', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_experience_availability_date'), ['date'], unique=False) + + op.drop_table('booking_items') + # ### end Alembic commands ### diff --git a/package-lock.json b/package-lock.json index 8d43d98ab7..44f1ece2dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -884,7 +885,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -997,14 +997,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", @@ -1018,6 +1010,7 @@ "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1066,6 +1059,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1294,6 +1288,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1312,8 +1307,7 @@ "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 + "optional": true }, "node_modules/call-bind": { "version": "1.0.8", @@ -1797,6 +1791,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3486,6 +3481,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3498,6 +3494,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3921,7 +3918,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "optional": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -3933,7 +3929,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4081,7 +4076,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -4100,8 +4094,7 @@ "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 + "optional": true }, "node_modules/text-table": { "version": "0.2.0", @@ -4278,6 +4271,7 @@ "integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -4500,6 +4494,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", "dev": true, + "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -4950,7 +4945,6 @@ "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" @@ -5044,14 +5038,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", @@ -5063,6 +5049,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "dev": true, + "peer": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5098,7 +5085,8 @@ "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -5245,6 +5233,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "dev": true, + "peer": true, "requires": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -5257,8 +5246,7 @@ "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 + "optional": true }, "call-bind": { "version": "1.0.8", @@ -5600,6 +5588,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6702,6 +6691,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -6710,6 +6700,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6988,7 +6979,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "optional": true, - "peer": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6999,8 +6989,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "optional": true, - "peer": true + "optional": true } } }, @@ -7100,7 +7089,6 @@ "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", "dev": true, "optional": true, - "peer": true, "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -7113,8 +7101,7 @@ "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 + "optional": true } } }, @@ -7228,6 +7215,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.9.tgz", "integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==", "dev": true, + "peer": true, "requires": { "esbuild": "^0.18.10", "fsevents": "~2.3.2", diff --git a/requirements.txt b/requirements.txt index 4eac45f4f8..886ee8f66c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,4 @@ sqlalchemy==1.3.23 urllib3==1.26.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' werkzeug==1.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' wtforms==2.3.3 +Flask-Mail==0.10.0 diff --git a/src/api/commands.py b/src/api/commands.py index 19806164d3..0df4cb90c1 100644 --- a/src/api/commands.py +++ b/src/api/commands.py @@ -1,4 +1,4 @@ - +# src/api/commands.py import click from api.models import db, User @@ -31,4 +31,11 @@ def insert_test_users(count): @app.cli.command("insert-test-data") def insert_test_data(): - pass \ No newline at end of file + pass + + # 👇 NUEVO COMANDO SEED + @app.cli.command("seed") + def seed_data(): + """Poblar la base de datos con datos de CaliaFarm""" + from api.seed import seed_database + seed_database() \ No newline at end of file diff --git a/src/api/email_service.py b/src/api/email_service.py new file mode 100644 index 0000000000..d37d621854 --- /dev/null +++ b/src/api/email_service.py @@ -0,0 +1,480 @@ +# src/api/email_service.py +from flask_mail import Mail, Message +from api.models import db, EmailLog, EmailStatus +from datetime import datetime +import os +from jinja2 import Template + +mail = Mail() + +def init_mail(app): + """Inicializar Flask-Mail con la app""" + mail.init_app(app) + +def send_email(recipient, subject, html_body, booking_id=None, email_type='general'): + """ + Enviar email usando Flask-Mail (SMTP) + """ + try: + msg = Message( + subject=subject, + recipients=[recipient], + html=html_body + ) + + mail.send(msg) + + # Registrar email enviado + if booking_id: + email_log = EmailLog( + booking_id=booking_id, + email_type=email_type, + recipient_email=recipient, + subject=subject, + status=EmailStatus.SENT, + sent_at=datetime.utcnow() + ) + db.session.add(email_log) + db.session.commit() + + print(f"✅ Email enviado a {recipient}") + return True + + except Exception as e: + print(f"❌ Error sending email: {str(e)}") + + # Registrar error + if booking_id: + email_log = EmailLog( + booking_id=booking_id, + email_type=email_type, + recipient_email=recipient, + subject=subject, + status=EmailStatus.FAILED, + error_message=str(e) + ) + db.session.add(email_log) + db.session.commit() + + return False + +def send_verification_email(user, token): + """Enviar email de verificación de cuenta""" + verification_url = f"{os.getenv('FRONTEND_URL')}/verify-email?token={token}" + + html_template = """ + + + + + + + +
+
+

¡Bienvenido a celiafarm!

+
+
+

Hola {{ name }},

+

Gracias por registrarte en celiafarm. Para completar tu registro, por favor verifica tu dirección de email haciendo clic en el botón de abajo:

+
+ Verificar mi email +
+

O copia y pega este enlace en tu navegador:

+

{{ verification_url }}

+

Este enlace expirará en 24 horas.

+

Si no creaste esta cuenta, puedes ignorar este email.

+
+ +
+ + + """ + + template = Template(html_template) + html_body = template.render( + name=user.name or user.email.split('@')[0], + verification_url=verification_url + ) + + return send_email( + recipient=user.email, + subject="Verifica tu cuenta en celiafarm", + html_body=html_body, + email_type='email_verification' + ) + +def send_password_reset_email(user, token): + """Enviar email de reset de contraseña""" + reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}" + + html_template = """ + + + + + + + +
+
+

Restablecer contraseña

+
+
+

Hola {{ name }},

+

Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña:

+
+ Restablecer contraseña +
+

O copia y pega este enlace en tu navegador:

+

{{ reset_url }}

+

Este enlace expirará en 2 horas.

+

Si no solicitaste restablecer tu contraseña, puedes ignorar este email de forma segura.

+
+ +
+ + + """ + + template = Template(html_template) + html_body = template.render( + name=user.name or user.email.split('@')[0], + reset_url=reset_url + ) + + return send_email( + recipient=user.email, + subject="Restablecer tu contraseña - celiafarm", + html_body=html_body, + email_type='password_reset' + ) + +def send_booking_confirmation_email(booking): + """Enviar email de confirmación de reserva""" + html_template = """ + + + + + + + +
+
+

✓ ¡Reserva Confirmada!

+
{{ confirmation_number }}
+

Guarda este número para futuras consultas

+
+
+

Hola {{ user_name }},

+

¡Gracias por tu reserva! Hemos recibido tu pago y tu reserva está confirmada.

+ +
+

📋 Detalles de tu reserva:

+ + {% if experience %} +
+ 🎯 Experiencia: + {{ experience.name }} +
+
+ 📅 Fecha: + {{ experience_date }} +
+ {% if experience_time %} +
+ 🕐 Hora: + {{ experience_time }} +
+ {% endif %} + {% endif %} + + {% if rooms %} +
+ 🏨 Alojamiento: + {{ check_in }} - {{ check_out }} +
+ {% for room in rooms %} +
+ 🛏️ Habitación: + {{ room.room.name }} ({{ room.nights }} noches) +
+ {% endfor %} + {% endif %} + +
+ 👥 Huéspedes: + {{ number_of_guests }} +
+ + {% if extras %} +

✨ Extras incluidos:

+ {% for extra in extras %} +
+ {{ extra.extra.name }}: + x{{ extra.quantity }} +
+ {% endfor %} + {% endif %} + +
+ 💰 TOTAL PAGADO: ${{ total_price }} +
+
+ + {% if special_requests %} +
+

📝 Solicitudes especiales:

+

{{ special_requests }}

+
+ {% endif %} + +

¡Esperamos verte pronto! 💜

+

El equipo de celiafarm

+
+ +
+ + + """ + + template = Template(html_template) + html_body = template.render( + confirmation_number=booking.confirmation_number, + user_name=booking.user.name or booking.user.email.split('@')[0], + experience=booking.experience, + experience_date=booking.experience_date.strftime('%d/%m/%Y') if booking.experience_date else None, + experience_time=booking.experience_time.strftime('%H:%M') if booking.experience_time else None, + check_in=booking.check_in.strftime('%d/%m/%Y') if booking.check_in else None, + check_out=booking.check_out.strftime('%d/%m/%Y') if booking.check_out else None, + rooms=booking.rooms, + number_of_guests=booking.number_of_guests, + extras=booking.extras, + total_price=f"{booking.total_price:.2f}", + special_requests=booking.special_requests + ) + + return send_email( + recipient=booking.user.email, + subject=f"✓ Confirmación de Reserva #{booking.confirmation_number} - HerSafe", + html_body=html_body, + booking_id=booking.id, + email_type='booking_confirmation' + ) + +def send_guest_checkout_email(booking, temporary_password=None): + """Enviar email a usuarios guest con contraseña temporal""" + html_template = """ + + + + + + + +
+
+

✓ ¡Reserva Confirmada!

+
{{ confirmation_number }}
+
+
+

Hola {{ user_name }},

+

¡Gracias por tu reserva! Tu pago ha sido confirmado.

+ + {% if temporary_password %} +
+

🔑 Hemos creado una cuenta para ti

+

Para que puedas revisar tu reserva, usa estos datos:

+
+ 📧 Email: {{ email }} +
+
+ 🔐 Contraseña temporal: {{ temporary_password }} +
+

+ ⚠️ Te recomendamos cambiar tu contraseña después de iniciar sesión. +

+
+ {% endif %} + +

Número de confirmación: {{ confirmation_number }}

+

¡Esperamos verte pronto! 💜

+
+ +
+ + + """ + + + template = Template(html_template) + html_body = template.render( + confirmation_number=booking.confirmation_number, + user_name=booking.user.name or booking.user.email.split('@')[0], + email=booking.user.email, + temporary_password=temporary_password + ) + + return send_email( + recipient=booking.user.email, + subject=f"Tu cuenta y reserva en celiafarm - {booking.confirmation_number}", + html_body=html_body, + booking_id=booking.id, + email_type='guest_checkout' + ) + +def send_booking_confirmation_email(booking_data): + """ + Envía email de confirmación de reserva + """ + try: + subject = f"Booking Confirmation #{booking_data['booking_number']} - CaliaFarm" + + # Preparar lista de items + items_html = "" + for item in booking_data.get('items', []): + items_html += f""" + + + {item['name']} + + + €{item['subtotal']:.2f} + + + """ + + html_body = f""" + + + + + + + +
+
+

🎉 Booking Confirmed!

+

Thank you for choosing CaliaFarm

+
+ +
+

Dear {booking_data['customer_name']},

+ +

We're delighted to confirm your booking at CaliaFarm. Your reservation has been successfully processed.

+ +
+

Booking Reference

+

#{booking_data['booking_number']}

+
+ +

Booking Details:

+ + {items_html} + + + + +
Total Paid:€{booking_data['total_amount']:.2f}
+ +

Customer Information:

+
    +
  • Name: {booking_data['customer_name']}
  • +
  • Email: {booking_data['customer_email']}
  • +
  • Phone: {booking_data['customer_phone']}
  • +
  • Payment Status: ✓ Paid
  • +
+ +

Important Information:

+
    +
  • Please arrive 15 minutes before your scheduled time
  • +
  • Bring this confirmation email with you
  • +
  • For cancellations, contact us at least 48 hours in advance
  • +
+ +

+ Contact Us +

+
+ + +
+ + + """ + + msg = Message( + subject=subject, + recipients=[booking_data['customer_email']], + html=html_body + ) + + mail.send(msg) + print(f"✅ Confirmation email sent to {booking_data['customer_email']}") + return True + + except Exception as e: + print(f"❌ Error sending confirmation email: {str(e)}") + raise e \ No newline at end of file diff --git a/src/api/models.py b/src/api/models.py index da515f6a1a..6cf3771f90 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,19 +1,529 @@ +# models.py (ACTUALIZACIONES) from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Boolean, Integer, Float, Text, Date, Time, DateTime, Enum as SQLEnum, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime, time +from enum import Enum +from typing import List, Optional +import secrets db = SQLAlchemy() +# ============= ENUMS ============= +class UserRole(Enum): + USER = "user" + ADMIN = "admin" + +class BookingStatus(Enum): + CART = "cart" + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + COMPLETED = "completed" + +class DayOfWeek(Enum): + MONDAY = "monday" + TUESDAY = "tuesday" + WEDNESDAY = "wednesday" + THURSDAY = "thursday" + FRIDAY = "friday" + SATURDAY = "saturday" + SUNDAY = "sunday" + +class ExtraType(Enum): + PER_BOOKING = "per_booking" + PER_GUEST = "per_guest" + +class PaymentStatus(Enum): + PENDING = "pending" + PROCESSING = "processing" + SUCCEEDED = "succeeded" + FAILED = "failed" + REFUNDED = "refunded" + +class EmailStatus(Enum): + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + +# ============= USUARIOS ============= class User(db.Model): + __tablename__ = 'users' + id: Mapped[int] = mapped_column(primary_key=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) - + email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True) + password: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Nullable para guest checkout + is_active: Mapped[bool] = mapped_column(Boolean(), default=True, nullable=False) + + role: Mapped[UserRole] = mapped_column(SQLEnum(UserRole), default=UserRole.USER, nullable=False) + name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + + # NUEVO: Sistema de verificación de email + email_verified: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) + verification_token: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, unique=True) + verification_token_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # NUEVO: Reset de contraseña + password_reset_token: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, unique=True) + password_reset_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # NUEVO: Para guest checkout (usuarios sin cuenta) + is_guest: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relaciones + bookings: Mapped[List["Booking"]] = relationship(back_populates='user', lazy='dynamic') def serialize(self): return { "id": self.id, "email": self.email, - # do not serialize the password, its a security breach + "is_active": self.is_active, + "role": self.role.value, + "name": self.name, + "phone": self.phone, + "email_verified": self.email_verified, + "is_guest": self.is_guest, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None + } + + def is_admin(self): + return self.role == UserRole.ADMIN + + def generate_verification_token(self): + """Generar token de verificación de email""" + self.verification_token = secrets.token_urlsafe(32) + self.verification_token_expires = datetime.utcnow() + timedelta(hours=24) + return self.verification_token + + def generate_password_reset_token(self): + """Generar token de reset de contraseña""" + self.password_reset_token = secrets.token_urlsafe(32) + self.password_reset_expires = datetime.utcnow() + timedelta(hours=2) + return self.password_reset_token + +# ============= EXPERIENCIAS (sin cambios) ============= +class Experience(db.Model): + __tablename__ = 'experiences' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + price: Mapped[float] = mapped_column(Float, nullable=False) + max_capacity: Mapped[int] = mapped_column(Integer, default=20, nullable=False) + duration_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean(), default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + schedules: Mapped[List["ExperienceSchedule"]] = relationship(back_populates='experience', cascade='all, delete-orphan') + bookings: Mapped[List["Booking"]] = relationship(back_populates='experience') + + def serialize(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'price': self.price, + 'max_capacity': self.max_capacity, + 'duration_hours': self.duration_hours, + 'image_url': self.image_url, + 'is_active': self.is_active, + 'schedules': [schedule.serialize() for schedule in self.schedules] + } + +class ExperienceSchedule(db.Model): + __tablename__ = 'experience_schedules' + + id: Mapped[int] = mapped_column(primary_key=True) + experience_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=False) + day_of_week: Mapped[DayOfWeek] = mapped_column(SQLEnum(DayOfWeek), nullable=False) + start_time: Mapped[time] = mapped_column(Time, nullable=False) + + experience: Mapped["Experience"] = relationship(back_populates='schedules') + + def serialize(self): + return { + 'id': self.id, + 'day_of_week': self.day_of_week.value, + 'start_time': self.start_time.strftime('%H:%M') if self.start_time else None + } + +# ============= HABITACIONES (sin cambios) ============= +class Room(db.Model): + __tablename__ = 'rooms' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + capacity: Mapped[int] = mapped_column(Integer, nullable=False) + price_per_night: Mapped[float] = mapped_column(Float, nullable=False) + image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + amenities: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean(), default=True) + check_in_time: Mapped[time] = mapped_column(Time, default=time(15, 0), nullable=False) + check_out_time: Mapped[time] = mapped_column(Time, default=time(11, 0), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + booking_rooms: Mapped[List["BookingRoom"]] = relationship(back_populates='room') + + def serialize(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'capacity': self.capacity, + 'price_per_night': self.price_per_night, + 'image_url': self.image_url, + 'amenities': self.amenities, + 'is_active': self.is_active, + 'check_in_time': self.check_in_time.strftime('%H:%M'), + 'check_out_time': self.check_out_time.strftime('%H:%M') + } + +# ============= EXTRAS (sin cambios) ============= +class Extra(db.Model): + __tablename__ = 'extras' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + price: Mapped[float] = mapped_column(Float, nullable=False) + type: Mapped[ExtraType] = mapped_column(SQLEnum(ExtraType), nullable=False) + image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean(), default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + booking_extras: Mapped[List["BookingExtra"]] = relationship(back_populates='extra') + + def serialize(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'price': self.price, + 'type': self.type.value, + 'image_url': self.image_url, + 'is_active': self.is_active + } + +# ============= PAQUETES (sin cambios) ============= +class Package(db.Model): + __tablename__ = 'packages' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + price: Mapped[float] = mapped_column(Float, nullable=False) + image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean(), default=True) + room_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('rooms.id'), nullable=True) + experience_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + room: Mapped[Optional["Room"]] = relationship() + experience: Mapped[Optional["Experience"]] = relationship() + included_extras: Mapped[List["PackageExtra"]] = relationship(back_populates='package', cascade='all, delete-orphan') + bookings: Mapped[List["Booking"]] = relationship(back_populates='package') + + def serialize(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'price': self.price, + 'image_url': self.image_url, + 'room': self.room.serialize() if self.room else None, + 'experience': self.experience.serialize() if self.experience else None, + 'included_extras': [pe.serialize() for pe in self.included_extras], + 'is_active': self.is_active + } + +class PackageExtra(db.Model): + __tablename__ = 'package_extras' + + id: Mapped[int] = mapped_column(primary_key=True) + package_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('packages.id'), nullable=False) + extra_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('extras.id'), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1) + + package: Mapped["Package"] = relationship(back_populates='included_extras') + extra: Mapped["Extra"] = relationship() + + def serialize(self): + return { + 'id': self.id, + 'extra': self.extra.serialize(), + 'quantity': self.quantity + } + +# ============= RESERVAS ============= +class Booking(db.Model): + __tablename__ = 'bookings' + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('users.id'), nullable=False) + + # NUEVO: Número de confirmación único + confirmation_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) + + experience_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=True) + package_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('packages.id'), nullable=True) + + experience_date: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True) + experience_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True) + + check_in: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True) + check_out: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True) + check_in_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True) + check_out_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True) + + number_of_guests: Mapped[int] = mapped_column(Integer, nullable=False) + + status: Mapped[BookingStatus] = mapped_column(SQLEnum(BookingStatus), default=BookingStatus.CART) + total_price: Mapped[float] = mapped_column(Float, nullable=False) + + stripe_payment_intent_id: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + stripe_payment_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + payment_status: Mapped[PaymentStatus] = mapped_column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING) + + special_requests: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + admin_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + cart_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relaciones + user: Mapped["User"] = relationship(back_populates='bookings') + experience: Mapped[Optional["Experience"]] = relationship(back_populates='bookings') + package: Mapped[Optional["Package"]] = relationship(back_populates='bookings') + rooms: Mapped[List["BookingRoom"]] = relationship(back_populates='booking', cascade='all, delete-orphan') + extras: Mapped[List["BookingExtra"]] = relationship(back_populates='booking', cascade='all, delete-orphan') + email_logs: Mapped[List["EmailLog"]] = relationship(back_populates='booking', cascade='all, delete-orphan') + + def serialize(self): + return { + 'id': self.id, + 'confirmation_number': self.confirmation_number, + 'user': self.user.serialize(), + 'experience': self.experience.serialize() if self.experience else None, + 'package': self.package.serialize() if self.package else None, + 'experience_date': self.experience_date.isoformat() if self.experience_date else None, + 'experience_time': self.experience_time.strftime('%H:%M') if self.experience_time else None, + 'check_in': self.check_in.isoformat() if self.check_in else None, + 'check_out': self.check_out.isoformat() if self.check_out else None, + 'check_in_time': self.check_in_time.strftime('%H:%M') if self.check_in_time else None, + 'check_out_time': self.check_out_time.strftime('%H:%M') if self.check_out_time else None, + 'number_of_guests': self.number_of_guests, + 'status': self.status.value, + 'payment_status': self.payment_status.value, + 'total_price': self.total_price, + 'rooms': [br.serialize() for br in self.rooms], + 'extras': [be.serialize() for be in self.extras], + 'special_requests': self.special_requests, + 'admin_notes': self.admin_notes, + 'stripe_payment_intent_id': self.stripe_payment_intent_id, + 'created_at': self.created_at.isoformat(), + 'updated_at': self.updated_at.isoformat(), + 'cart_expires_at': self.cart_expires_at.isoformat() if self.cart_expires_at else None + } + + def serialize_admin(self): + data = self.serialize() + data['stripe_details'] = { + 'payment_intent_id': self.stripe_payment_intent_id, + 'payment_status': self.stripe_payment_status, + 'payment_status_enum': self.payment_status.value + } + return data + + @staticmethod + def generate_confirmation_number(): + """Generar número de confirmación único (ej: BK20240115ABCD)""" + import random + import string + date_str = datetime.utcnow().strftime('%Y%m%d') + random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4)) + return f"BK{date_str}{random_str}" + +class BookingRoom(db.Model): + __tablename__ = 'booking_rooms' + + id: Mapped[int] = mapped_column(primary_key=True) + booking_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('bookings.id'), nullable=False) + room_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('rooms.id'), nullable=False) + check_in: Mapped[datetime] = mapped_column(Date, nullable=False) + check_out: Mapped[datetime] = mapped_column(Date, nullable=False) + nights: Mapped[int] = mapped_column(Integer, nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + + booking: Mapped["Booking"] = relationship(back_populates='rooms') + room: Mapped["Room"] = relationship(back_populates='booking_rooms') + + def serialize(self): + return { + 'id': self.id, + 'room': self.room.serialize(), + 'check_in': self.check_in.isoformat(), + 'check_out': self.check_out.isoformat(), + 'nights': self.nights, + 'price': self.price + } + +class BookingExtra(db.Model): + __tablename__ = 'booking_extras' + + id: Mapped[int] = mapped_column(primary_key=True) + booking_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('bookings.id'), nullable=False) + extra_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('extras.id'), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1) + price: Mapped[float] = mapped_column(Float, nullable=False) + + booking: Mapped["Booking"] = relationship(back_populates='extras') + extra: Mapped["Extra"] = relationship(back_populates='booking_extras') + + def serialize(self): + return { + 'id': self.id, + 'extra': self.extra.serialize(), + 'quantity': self.quantity, + 'price': self.price + } + +# ============= EMAIL LOG ============= +class EmailLog(db.Model): + __tablename__ = 'email_logs' + + id: Mapped[int] = mapped_column(primary_key=True) + booking_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('bookings.id'), nullable=False) + email_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'booking_confirmation', 'payment_receipt', etc. + recipient_email: Mapped[str] = mapped_column(String(120), nullable=False) + subject: Mapped[str] = mapped_column(String(200), nullable=False) + status: Mapped[EmailStatus] = mapped_column(SQLEnum(EmailStatus), default=EmailStatus.PENDING) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + booking: Mapped["Booking"] = relationship(back_populates='email_logs') + + def serialize(self): + return { + 'id': self.id, + 'booking_id': self.booking_id, + 'email_type': self.email_type, + 'recipient_email': self.recipient_email, + 'subject': self.subject, + 'status': self.status.value, + 'error_message': self.error_message, + 'sent_at': self.sent_at.isoformat() if self.sent_at else None, + 'created_at': self.created_at.isoformat() + } + +# ============= DISPONIBILIDAD (sin cambios) ============= +class RoomAvailability(db.Model): + __tablename__ = 'room_availability' + + id: Mapped[int] = mapped_column(primary_key=True) + room_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('rooms.id'), nullable=False) + date: Mapped[datetime] = mapped_column(Date, nullable=False, index=True) + is_available: Mapped[bool] = mapped_column(Boolean(), default=True) + reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + room: Mapped["Room"] = relationship() + + __table_args__ = ( + db.UniqueConstraint('room_id', 'date', name='_room_date_uc'), + ) + + def serialize(self): + return { + 'id': self.id, + 'room_id': self.room_id, + 'date': self.date.isoformat(), + 'is_available': self.is_available, + 'reason': self.reason + } + +class ExperienceAvailability(db.Model): + __tablename__ = 'experience_availability' + + id: Mapped[int] = mapped_column(primary_key=True) + experience_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=False) + date: Mapped[datetime] = mapped_column(Date, nullable=False, index=True) + available_spots: Mapped[int] = mapped_column(Integer, nullable=False) + + experience: Mapped["Experience"] = relationship() + + __table_args__ = ( + db.UniqueConstraint('experience_id', 'date', name='_experience_date_uc'), + ) + + def serialize(self): + return { + 'id': self.id, + 'experience_id': self.experience_id, + 'date': self.date.isoformat(), + 'available_spots': self.available_spots + } + + + __tablename__ = 'booking_items' + + id = db.Column(db.Integer, primary_key=True) + booking_id = db.Column(db.Integer, db.ForeignKey('bookings.id'), nullable=False) + + # Item Type (experience or room) + item_type = db.Column(db.String(50), nullable=False) # 'experience' or 'room' + + # Reference to the actual item + experience_id = db.Column(db.Integer, db.ForeignKey('experiences.id'), nullable=True) + room_id = db.Column(db.Integer, db.ForeignKey('rooms.id'), nullable=True) + + # Item Details (cached for historical record) + name = db.Column(db.String(200), nullable=False) + image_url = db.Column(db.String(500)) + + # Experience-specific fields + date = db.Column(db.Date, nullable=True) # For experiences + guests = db.Column(db.Integer) + + # Room-specific fields + check_in = db.Column(db.Date, nullable=True) # For rooms + check_out = db.Column(db.Date, nullable=True) # For rooms + nights = db.Column(db.Integer) + + # Pricing + unit_price = db.Column(db.Float, nullable=False) + subtotal = db.Column(db.Float, nullable=False) + + # Extras (stored as JSON) + extras = db.Column(db.JSON) # [{id, name, price}, ...] + + # Timestamps + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + experience = db.relationship('Experience', backref='booking_items') + room = db.relationship('Room', backref='booking_items') + + def serialize(self): + return { + 'id': self.id, + 'type': self.item_type, + 'name': self.name, + 'image_url': self.image_url, + 'date': self.date.isoformat() if self.date else None, + 'check_in': self.check_in.isoformat() if self.check_in else None, + 'check_out': self.check_out.isoformat() if self.check_out else None, + 'guests': self.guests, + 'nights': self.nights, + 'unit_price': self.unit_price, + 'subtotal': self.subtotal, + 'extras': self.extras, + 'experience_id': self.experience_id, + 'room_id': self.room_id } \ No newline at end of file diff --git a/src/api/routes.py b/src/api/routes.py index 029589a3a1..6ceb6b3327 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -2,21 +2,1636 @@ 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.models import ( + db, User, Experience, ExperienceSchedule, Room, Extra, Package, PackageExtra, + Booking, BookingRoom, BookingExtra, RoomAvailability, ExperienceAvailability, + BookingStatus, PaymentStatus, EmailStatus, EmailLog, UserRole, ExtraType, DayOfWeek +) from api.utils import generate_sitemap, APIException +from api.email_service import ( + send_verification_email, + send_password_reset_email, + send_booking_confirmation_email, + send_guest_checkout_email +) from flask_cors import CORS +from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token +from werkzeug.security import generate_password_hash, check_password_hash +from datetime import datetime, timedelta, date, time +from sqlalchemy import and_, or_, func +import stripe +import random +import os +import secrets +import string +from flask import Blueprint, request, jsonify api = Blueprint('api', __name__) - -# Allow CORS requests to this API CORS(api) +stripe.api_key = os.getenv('STRIPE_SECRET_KEY') + +# ============= DECORADOR PARA ADMIN ============= +def admin_required(): + def wrapper(fn): + @jwt_required() + def decorator(*args, **kwargs): + user_id = get_jwt_identity() + user = User.query.get(user_id) + if not user or not user.is_admin(): + return jsonify({"error": "Admin access required"}), 403 + return fn(*args, **kwargs) + decorator.__name__ = fn.__name__ + return decorator + return wrapper + +# ============= HELPER: LIMPIAR CARRITOS EXPIRADOS ============= +def clean_expired_carts(): + """Eliminar carritos que hayan expirado (más de 30 minutos)""" + expired_bookings = Booking.query.filter( + Booking.status == BookingStatus.CART, + Booking.cart_expires_at < datetime.utcnow() + ).all() + + for booking in expired_bookings: + db.session.delete(booking) + + if expired_bookings: + db.session.commit() + +def generate_temporary_password(length=12): + """Generar contraseña temporal segura""" + characters = string.ascii_letters + string.digits + "!@#$%^&*()" + return ''.join(secrets.choice(characters) for _ in range(length)) + +# ============= AUTENTICACIÓN COMPLETA ============= +@api.route('/register', methods=['POST']) +def register(): + """ + Registro de usuario con verificación de email + Body: { + "email": "user@example.com", + "password": "securepassword", + "name": "John Doe", + "phone": "+1234567890" + } + """ + data = request.get_json() + + if not data.get('email') or not data.get('password'): + return jsonify({"error": "Email and password are required"}), 400 + + # Validar formato de email + import re + email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_regex, data['email']): + return jsonify({"error": "Invalid email format"}), 400 + + # Verificar si el email ya existe + if User.query.filter_by(email=data['email']).first(): + return jsonify({"error": "Email already exists"}), 400 + + # Validar longitud de contraseña + if len(data['password']) < 8: + return jsonify({"error": "Password must be at least 8 characters long"}), 400 + + try: + user = User( + email=data['email'].lower(), + password=generate_password_hash(data['password']), + name=data.get('name'), + phone=data.get('phone'), + role=UserRole.USER, + is_active=True, + email_verified=False, + is_guest=False + ) + + # Generar token de verificación + token = user.generate_verification_token() + + db.session.add(user) + db.session.commit() + + # Enviar email de verificación + send_verification_email(user, token) + + # Crear token JWT + access_token = create_access_token(identity=user.id) + + return jsonify({ + "message": "User registered successfully. Please check your email to verify your account.", + "user": user.serialize(), + "token": access_token, + "email_verification_required": True + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + +@api.route('/verify-email', methods=['POST']) +def verify_email(): + """ + Verificar email con token + Body: { "token": "verification_token_here" } + """ + data = request.get_json() + + if not data.get('token'): + return jsonify({"error": "Token is required"}), 400 + + user = User.query.filter_by(verification_token=data['token']).first() + + if not user: + return jsonify({"error": "Invalid verification token"}), 400 + + if user.verification_token_expires < datetime.utcnow(): + return jsonify({"error": "Verification token has expired"}), 400 + + user.email_verified = True + user.verification_token = None + user.verification_token_expires = None + + db.session.commit() + + return jsonify({ + "message": "Email verified successfully", + "user": user.serialize() + }), 200 + +@api.route('/resend-verification', methods=['POST']) +@jwt_required() +def resend_verification(): + """Reenviar email de verificación""" + user_id = get_jwt_identity() + user = User.query.get(user_id) + + if not user: + return jsonify({"error": "User not found"}), 404 + + if user.email_verified: + return jsonify({"error": "Email already verified"}), 400 + + # Generar nuevo token + token = user.generate_verification_token() + db.session.commit() + + # Enviar email + send_verification_email(user, token) + + return jsonify({"message": "Verification email sent"}), 200 + +@api.route('/login', methods=['POST']) +def login(): + """ + Login de usuario + Body: { + "email": "user@example.com", + "password": "password" + } + """ + data = request.get_json() + + if not data.get('email') or not data.get('password'): + return jsonify({"error": "Email and password are required"}), 400 + + user = User.query.filter_by(email=data['email'].lower()).first() + + if not user or not user.password: + return jsonify({"error": "Invalid credentials"}), 401 + + if not check_password_hash(user.password, data['password']): + return jsonify({"error": "Invalid credentials"}), 401 + + if not user.is_active: + return jsonify({"error": "Account is inactive"}), 401 + + # Actualizar último login + user.last_login = datetime.utcnow() + db.session.commit() + + access_token = create_access_token(identity=user.id) + + return jsonify({ + "message": "Login successful", + "user": user.serialize(), + "token": access_token, + "email_verified": user.email_verified + }), 200 + +@api.route('/forgot-password', methods=['POST']) +def forgot_password(): + """ + Solicitar reset de contraseña + Body: { "email": "user@example.com" } + """ + data = request.get_json() + + if not data.get('email'): + return jsonify({"error": "Email is required"}), 400 + + user = User.query.filter_by(email=data['email'].lower()).first() + + # Por seguridad, siempre devolver éxito aunque el email no exista + if user and not user.is_guest: + token = user.generate_password_reset_token() + db.session.commit() + + send_password_reset_email(user, token) + + return jsonify({ + "message": "If that email exists, we've sent password reset instructions" + }), 200 + +@api.route('/reset-password', methods=['POST']) +def reset_password(): + """ + Resetear contraseña con token + Body: { + "token": "reset_token_here", + "new_password": "newpassword123" + } + """ + data = request.get_json() + + if not data.get('token') or not data.get('new_password'): + return jsonify({"error": "Token and new password are required"}), 400 + + if len(data['new_password']) < 8: + return jsonify({"error": "Password must be at least 8 characters long"}), 400 + + user = User.query.filter_by(password_reset_token=data['token']).first() + + if not user: + return jsonify({"error": "Invalid reset token"}), 400 + + if user.password_reset_expires < datetime.utcnow(): + return jsonify({"error": "Reset token has expired"}), 400 + + user.password = generate_password_hash(data['new_password']) + user.password_reset_token = None + user.password_reset_expires = None + + db.session.commit() + + return jsonify({"message": "Password reset successfully"}), 200 + +@api.route('/change-password', methods=['POST']) +@jwt_required() +def change_password(): + """ + Cambiar contraseña (usuario logueado) + Body: { + "current_password": "oldpassword", + "new_password": "newpassword123" + } + """ + user_id = get_jwt_identity() + data = request.get_json() + + if not data.get('current_password') or not data.get('new_password'): + return jsonify({"error": "Current and new password are required"}), 400 + + user = User.query.get(user_id) + + if not user or not user.password: + return jsonify({"error": "User not found"}), 404 + + if not check_password_hash(user.password, data['current_password']): + return jsonify({"error": "Current password is incorrect"}), 401 + + if len(data['new_password']) < 8: + return jsonify({"error": "New password must be at least 8 characters long"}), 400 + + user.password = generate_password_hash(data['new_password']) + db.session.commit() + + return jsonify({"message": "Password changed successfully"}), 200 + +@api.route('/me', methods=['GET']) +@jwt_required() +def get_current_user(): + user_id = get_jwt_identity() + user = User.query.get(user_id) + + if not user: + return jsonify({"error": "User not found"}), 404 + + return jsonify(user.serialize()), 200 + +@api.route('/me', methods=['PUT']) +@jwt_required() +def update_profile(): + """ + Actualizar perfil de usuario + Body: { + "name": "New Name", + "phone": "+1234567890" + } + """ + user_id = get_jwt_identity() + user = User.query.get(user_id) + + if not user: + return jsonify({"error": "User not found"}), 404 + + data = request.get_json() + + if data.get('name'): + user.name = data['name'] + + if data.get('phone'): + user.phone = data['phone'] + + db.session.commit() + + return jsonify({ + "message": "Profile updated successfully", + "user": user.serialize() + }), 200 + +# ============= EXPERIENCIAS (sin cambios del código anterior) ============= +@api.route('/experiences', methods=['GET']) +def get_experiences(): + experiences = Experience.query.filter_by(is_active=True).all() + return jsonify([exp.serialize() for exp in experiences]), 200 + +@api.route('/experiences/', methods=['GET']) +def get_experience(experience_id): + experience = Experience.query.get(experience_id) + if not experience: + return jsonify({"error": "Experience not found"}), 404 + + return jsonify(experience.serialize()), 200 + +@api.route('/experiences/available', methods=['POST']) +def get_available_experiences(): + data = request.get_json() + + try: + start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() + end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() + guests = int(data.get('guests', 1)) + except (KeyError, ValueError): + return jsonify({"error": "Invalid date format or missing parameters"}), 400 + + experiences = Experience.query.filter_by(is_active=True).all() + available_experiences = [] + + for experience in experiences: + schedule_days = [schedule.day_of_week for schedule in experience.schedules] + available_dates = [] + current_date = start_date + + while current_date <= end_date: + day_name = current_date.strftime('%A').upper() + try: + day_enum = DayOfWeek[day_name] + except KeyError: + current_date += timedelta(days=1) + continue + + if day_enum in schedule_days: + confirmed_bookings = Booking.query.filter( + Booking.experience_id == experience.id, + Booking.experience_date == current_date, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING]) + ).all() + + booked_spots = sum([b.number_of_guests for b in confirmed_bookings]) + available_spots = experience.max_capacity - booked_spots + + if available_spots >= guests: + schedule = next((s for s in experience.schedules if s.day_of_week == day_enum), None) + available_dates.append({ + 'date': current_date.isoformat(), + 'available_spots': available_spots, + 'day_of_week': day_name.lower(), + 'start_time': schedule.start_time.strftime('%H:%M') if schedule else None + }) + + current_date += timedelta(days=1) + + if available_dates: + exp_data = experience.serialize() + exp_data['available_dates'] = available_dates + available_experiences.append(exp_data) + + return jsonify(available_experiences), 200 + +# ============= HABITACIONES (sin cambios) ============= +@api.route('/rooms', methods=['GET']) +def get_rooms(): + rooms = Room.query.filter_by(is_active=True).all() + return jsonify([room.serialize() for room in rooms]), 200 + +@api.route('/rooms/', methods=['GET']) +def get_room(room_id): + room = Room.query.get(room_id) + if not room: + return jsonify({"error": "Room not found"}), 404 + + return jsonify(room.serialize()), 200 + +@api.route('/rooms/available', methods=['POST']) +def get_available_rooms(): + data = request.get_json() + + try: + check_in = datetime.strptime(data['check_in'], '%Y-%m-%d').date() + check_out = datetime.strptime(data['check_out'], '%Y-%m-%d').date() + except (KeyError, ValueError): + return jsonify({"error": "Invalid date format or missing parameters"}), 400 + + if check_in >= check_out: + return jsonify({"error": "Check-out must be after check-in"}), 400 + + rooms = Room.query.filter_by(is_active=True).all() + available_rooms = [] + + for room in rooms: + is_available = True + current_date = check_in + + while current_date < check_out: + existing_bookings = BookingRoom.query.join(Booking).filter( + BookingRoom.room_id == room.id, + BookingRoom.check_in <= current_date, + BookingRoom.check_out > current_date, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING]) + ).first() + + manual_block = RoomAvailability.query.filter_by( + room_id=room.id, + date=current_date, + is_available=False + ).first() + + if existing_bookings or manual_block: + is_available = False + break + + current_date += timedelta(days=1) + + if is_available: + nights = (check_out - check_in).days + room_data = room.serialize() + room_data['nights'] = nights + room_data['total_price'] = room.price_per_night * nights + available_rooms.append(room_data) + + return jsonify(available_rooms), 200 + +# ============= EXTRAS ============= +@api.route('/extras', methods=['GET']) +def get_extras(): + extras = Extra.query.filter_by(is_active=True).all() + return jsonify([extra.serialize() for extra in extras]), 200 + +# ============= PAQUETES ============= +@api.route('/packages', methods=['GET']) +def get_packages(): + packages = Package.query.filter_by(is_active=True).all() + return jsonify([package.serialize() for package in packages]), 200 + +@api.route('/packages/', methods=['GET']) +def get_package(package_id): + package = Package.query.get(package_id) + if not package: + return jsonify({"error": "Package not found"}), 404 + + return jsonify(package.serialize()), 200 + +# ============= CARRITO DE COMPRAS ============= +@api.route('/cart', methods=['POST']) +@jwt_required() +def add_to_cart(): + """Agregar item al carrito""" + user_id = get_jwt_identity() + clean_expired_carts() + + data = request.get_json() + + try: + number_of_guests = int(data['number_of_guests']) + total_price = 0 + + # Parsear fechas y horarios + experience_date = None + experience_time = None + if data.get('experience_date'): + experience_date = datetime.strptime(data['experience_date'], '%Y-%m-%d').date() + if data.get('experience_time'): + experience_time = datetime.strptime(data['experience_time'], '%H:%M').time() + + check_in = None + check_out = None + check_in_time = None + check_out_time = None + + if data.get('check_in') and data.get('check_out'): + check_in = datetime.strptime(data['check_in'], '%Y-%m-%d').date() + check_out = datetime.strptime(data['check_out'], '%Y-%m-%d').date() + + if data.get('check_in_time'): + check_in_time = datetime.strptime(data['check_in_time'], '%H:%M').time() + if data.get('check_out_time'): + check_out_time = datetime.strptime(data['check_out_time'], '%H:%M').time() + + # Validar experiencia + experience = None + if data.get('experience_id'): + experience = Experience.query.get(data['experience_id']) + if not experience or not experience.is_active: + return jsonify({"error": "Invalid experience"}), 400 + total_price += experience.price * number_of_guests + + # Validar paquete + package = None + if data.get('package_id'): + package = Package.query.get(data['package_id']) + if not package or not package.is_active: + return jsonify({"error": "Invalid package"}), 400 + total_price += package.price + + # Generar número de confirmación + confirmation_number = Booking.generate_confirmation_number() + + # Crear booking en carrito + booking = Booking( + user_id=user_id, + confirmation_number=confirmation_number, + experience_id=data.get('experience_id'), + package_id=data.get('package_id'), + experience_date=experience_date, + experience_time=experience_time, + check_in=check_in, + check_out=check_out, + check_in_time=check_in_time, + check_out_time=check_out_time, + number_of_guests=number_of_guests, + status=BookingStatus.CART, + payment_status=PaymentStatus.PENDING, + total_price=0, + special_requests=data.get('special_requests'), + cart_expires_at=datetime.utcnow() + timedelta(minutes=30) + ) + + db.session.add(booking) + db.session.flush() + + # Agregar habitaciones + if data.get('rooms') and check_in and check_out: + nights = (check_out - check_in).days + + for room_data in data['rooms']: + room = Room.query.get(room_data['room_id']) + if not room or not room.is_active: + db.session.rollback() + return jsonify({"error": f"Invalid room {room_data['room_id']}"}), 400 + + room_price = room.price_per_night * nights + total_price += room_price + + booking_room = BookingRoom( + booking_id=booking.id, + room_id=room.id, + check_in=check_in, + check_out=check_out, + nights=nights, + price=room_price + ) + db.session.add(booking_room) + + # Agregar extras + if data.get('extras'): + for extra_data in data['extras']: + extra = Extra.query.get(extra_data['extra_id']) + if not extra or not extra.is_active: + db.session.rollback() + return jsonify({"error": f"Invalid extra {extra_data['extra_id']}"}), 400 + + quantity = extra_data.get('quantity', 1) + + if extra.type == ExtraType.PER_BOOKING: + extra_price = extra.price + else: + extra_price = extra.price * number_of_guests * quantity + + total_price += extra_price + + booking_extra = BookingExtra( + booking_id=booking.id, + extra_id=extra.id, + quantity=quantity, + price=extra_price + ) + db.session.add(booking_extra) + + booking.total_price = total_price + db.session.commit() + + return jsonify({ + "message": "Item added to cart", + "booking": booking.serialize() + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + +@api.route('/cart', methods=['GET']) +@jwt_required() +def get_cart(): + user_id = get_jwt_identity() + clean_expired_carts() + + cart_items = Booking.query.filter_by( + user_id=user_id, + status=BookingStatus.CART + ).all() + + return jsonify([item.serialize() for item in cart_items]), 200 + +@api.route('/cart/', methods=['PUT']) +@jwt_required() +def update_cart_item(booking_id): + user_id = get_jwt_identity() + + booking = Booking.query.filter_by( + id=booking_id, + user_id=user_id, + status=BookingStatus.CART + ).first() + + if not booking: + return jsonify({"error": "Cart item not found"}), 404 + + data = request.get_json() + + try: + if data.get('number_of_guests'): + booking.number_of_guests = int(data['number_of_guests']) + + if 'special_requests' in data: + booking.special_requests = data['special_requests'] + + if data.get('extras'): + BookingExtra.query.filter_by(booking_id=booking.id).delete() + + total_price = 0 + + if booking.experience: + total_price += booking.experience.price * booking.number_of_guests + + if booking.package: + total_price += booking.package.price + + for booking_room in booking.rooms: + total_price += booking_room.price + + for extra_data in data['extras']: + extra = Extra.query.get(extra_data['extra_id']) + if extra and extra.is_active: + quantity = extra_data.get('quantity', 1) + + if extra.type == ExtraType.PER_BOOKING: + extra_price = extra.price + else: + extra_price = extra.price * booking.number_of_guests * quantity + + total_price += extra_price + + booking_extra = BookingExtra( + booking_id=booking.id, + extra_id=extra.id, + quantity=quantity, + price=extra_price + ) + db.session.add(booking_extra) + + booking.total_price = total_price + + booking.cart_expires_at = datetime.utcnow() + timedelta(minutes=30) + booking.updated_at = datetime.utcnow() + + db.session.commit() + + return jsonify({ + "message": "Cart item updated", + "booking": booking.serialize() + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + +@api.route('/cart/', methods=['DELETE']) +@jwt_required() +def remove_from_cart(booking_id): + user_id = get_jwt_identity() + + booking = Booking.query.filter_by( + id=booking_id, + user_id=user_id, + status=BookingStatus.CART + ).first() + + if not booking: + return jsonify({"error": "Cart item not found"}), 404 + + db.session.delete(booking) + db.session.commit() + + return jsonify({"message": "Item removed from cart"}), 200 + +@api.route('/cart/clear', methods=['DELETE']) +@jwt_required() +def clear_cart(): + user_id = get_jwt_identity() + + cart_items = Booking.query.filter_by( + user_id=user_id, + status=BookingStatus.CART + ).all() + + for item in cart_items: + db.session.delete(item) + + db.session.commit() + + return jsonify({"message": "Cart cleared"}), 200 + +# ============= GUEST CHECKOUT ============= +@api.route('/guest-checkout', methods=['POST']) +def guest_checkout(): + """ + Checkout como invitado (sin registro previo) + Body: { + "email": "guest@example.com", + "name": "Guest User", + "phone": "+1234567890", + "booking_data": { ... datos del carrito ... } + } + """ + data = request.get_json() + + if not data.get('email'): + return jsonify({"error": "Email is required"}), 400 + + email = data['email'].lower() + + try: + # Verificar si el usuario ya existe + user = User.query.filter_by(email=email).first() + + if not user: + # Crear usuario guest con contraseña temporal + temp_password = generate_temporary_password() + + user = User( + email=email, + password=generate_password_hash(temp_password), + name=data.get('name'), + phone=data.get('phone'), + role=UserRole.USER, + is_active=True, + email_verified=False, + is_guest=True + ) + + db.session.add(user) + db.session.flush() + + user_created = True + else: + temp_password = None + user_created = False + + # Procesar booking (similar a add_to_cart pero directo a PENDING) + booking_data = data.get('booking_data', {}) + + number_of_guests = int(booking_data['number_of_guests']) + total_price = 0 + + # Parsear fechas + experience_date = None + experience_time = None + if booking_data.get('experience_date'): + experience_date = datetime.strptime(booking_data['experience_date'], '%Y-%m-%d').date() + if booking_data.get('experience_time'): + experience_time = datetime.strptime(booking_data['experience_time'], '%H:%M').time() + + check_in = None + check_out = None + check_in_time = None + check_out_time = None + + if booking_data.get('check_in') and booking_data.get('check_out'): + check_in = datetime.strptime(booking_data['check_in'], '%Y-%m-%d').date() + check_out = datetime.strptime(booking_data['check_out'], '%Y-%m-%d').date() + + if booking_data.get('check_in_time'): + check_in_time = datetime.strptime(booking_data['check_in_time'], '%H:%M').time() + if booking_data.get('check_out_time'): + check_out_time = datetime.strptime(booking_data['check_out_time'], '%H:%M').time() + + # Validar experiencia + experience = None + if booking_data.get('experience_id'): + experience = Experience.query.get(booking_data['experience_id']) + if not experience or not experience.is_active: + db.session.rollback() + return jsonify({"error": "Invalid experience"}), 400 + total_price += experience.price * number_of_guests + + # Validar paquete + package = None + if booking_data.get('package_id'): + package = Package.query.get(booking_data['package_id']) + if not package or not package.is_active: + db.session.rollback() + return jsonify({"error": "Invalid package"}), 400 + total_price += package.price + + confirmation_number = Booking.generate_confirmation_number() + + booking = Booking( + user_id=user.id, + confirmation_number=confirmation_number, + experience_id=booking_data.get('experience_id'), + package_id=booking_data.get('package_id'), + experience_date=experience_date, + experience_time=experience_time, + check_in=check_in, + check_out=check_out, + check_in_time=check_in_time, + check_out_time=check_out_time, + number_of_guests=number_of_guests, + status=BookingStatus.PENDING, + payment_status=PaymentStatus.PENDING, + total_price=0, + special_requests=booking_data.get('special_requests') + ) + + db.session.add(booking) + db.session.flush() + + # Agregar habitaciones + if booking_data.get('rooms') and check_in and check_out: + nights = (check_out - check_in).days + + for room_data in booking_data['rooms']: + room = Room.query.get(room_data['room_id']) + if not room or not room.is_active: + db.session.rollback() + return jsonify({"error": f"Invalid room {room_data['room_id']}"}), 400 + + room_price = room.price_per_night * nights + total_price += room_price + + booking_room = BookingRoom( + booking_id=booking.id, + room_id=room.id, + check_in=check_in, + check_out=check_out, + nights=nights, + price=room_price + ) + db.session.add(booking_room) + + # Agregar extras + if booking_data.get('extras'): + for extra_data in booking_data['extras']: + extra = Extra.query.get(extra_data['extra_id']) + if not extra or not extra.is_active: + db.session.rollback() + return jsonify({"error": f"Invalid extra {extra_data['extra_id']}"}), 400 + + quantity = extra_data.get('quantity', 1) + + if extra.type == ExtraType.PER_BOOKING: + extra_price = extra.price + else: + extra_price = extra.price * number_of_guests * quantity + + total_price += extra_price + + booking_extra = BookingExtra( + booking_id=booking.id, + extra_id=extra.id, + quantity=quantity, + price=extra_price + ) + db.session.add(booking_extra) + + booking.total_price = total_price + + # Crear Payment Intent + intent = stripe.PaymentIntent.create( + amount=int(total_price * 100), + currency='usd', + metadata={ + 'user_id': user.id, + 'booking_ids': str(booking.id), + 'is_guest': str(user_created) + } + ) + + booking.stripe_payment_intent_id = intent.id + booking.stripe_payment_status = intent.status + booking.payment_status = PaymentStatus.PROCESSING + + db.session.commit() + + # Si se creó usuario nuevo, enviar email con credenciales + if user_created and temp_password: + send_guest_checkout_email(booking, temp_password) + + return jsonify({ + "message": "Guest checkout initiated", + "client_secret": intent.client_secret, + "payment_intent_id": intent.id, + "booking": booking.serialize(), + "user_created": user_created, + "temporary_password": temp_password if user_created else None + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + +# ============= CHECKOUT (USUARIO REGISTRADO) ============= +@api.route('/checkout', methods=['POST']) +@jwt_required() +def checkout(): + """Procesar checkout - convierte items del carrito a PENDING""" + user_id = get_jwt_identity() + data = request.get_json() + + if not data.get('booking_ids'): + return jsonify({"error": "No booking IDs provided"}), 400 + + booking_ids = data['booking_ids'] + + bookings = Booking.query.filter( + Booking.id.in_(booking_ids), + Booking.user_id == user_id, + Booking.status == BookingStatus.CART + ).all() + + if not bookings: + return jsonify({"error": "No valid cart items found"}), 404 + + # Validar disponibilidad + for booking in bookings: + if booking.experience_id and booking.experience_date: + confirmed_bookings = Booking.query.filter( + Booking.experience_id == booking.experience_id, + Booking.experience_date == booking.experience_date, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING]), + Booking.id != booking.id + ).all() + + booked_spots = sum([b.number_of_guests for b in confirmed_bookings]) + available_spots = booking.experience.max_capacity - booked_spots + + if available_spots < booking.number_of_guests: + return jsonify({ + "error": f"Experience '{booking.experience.name}' no longer has enough spots available" + }), 400 + + for booking_room in booking.rooms: + current_date = booking_room.check_in + while current_date < booking_room.check_out: + existing = BookingRoom.query.join(Booking).filter( + BookingRoom.room_id == booking_room.room_id, + BookingRoom.check_in <= current_date, + BookingRoom.check_out > current_date, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING]), + Booking.id != booking.id + ).first() + + if existing: + return jsonify({ + "error": f"Room '{booking_room.room.name}' is no longer available for selected dates" + }), 400 + + current_date += timedelta(days=1) + + total_amount = sum([b.total_price for b in bookings]) + + try: + intent = stripe.PaymentIntent.create( + amount=int(total_amount * 100), + currency='usd', + metadata={ + 'user_id': user_id, + 'booking_ids': ','.join([str(b.id) for b in bookings]) + } + ) + + for booking in bookings: + booking.status = BookingStatus.PENDING + booking.payment_status = PaymentStatus.PROCESSING + booking.stripe_payment_intent_id = intent.id + booking.stripe_payment_status = intent.status + booking.cart_expires_at = None + + db.session.commit() + + return jsonify({ + "message": "Checkout initiated", + "client_secret": intent.client_secret, + "payment_intent_id": intent.id, + "total_amount": total_amount, + "bookings": [b.serialize() for b in bookings] + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + +# ============= WEBHOOK STRIPE CON ENVÍO DE EMAILS ============= +@api.route('/webhooks/stripe', methods=['POST']) +def stripe_webhook(): + payload = request.get_data() + sig_header = request.headers.get('Stripe-Signature') + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, os.getenv('STRIPE_WEBHOOK_SECRET') + ) + except ValueError: + return jsonify({"error": "Invalid payload"}), 400 + except stripe.error.SignatureVerificationError: + return jsonify({"error": "Invalid signature"}), 400 + + if event['type'] == 'payment_intent.succeeded': + payment_intent = event['data']['object'] + booking_ids = payment_intent['metadata']['booking_ids'].split(',') + + for booking_id in booking_ids: + booking = Booking.query.get(int(booking_id)) + if booking: + booking.payment_status = PaymentStatus.SUCCEEDED + booking.status = BookingStatus.CONFIRMED + booking.stripe_payment_status = payment_intent['status'] + + # ENVIAR EMAIL DE CONFIRMACIÓN + try: + send_booking_confirmation_email(booking) + except Exception as e: + print(f"Error sending confirmation email: {str(e)}") + + db.session.commit() + + elif event['type'] == 'payment_intent.payment_failed': + payment_intent = event['data']['object'] + booking_ids = payment_intent['metadata']['booking_ids'].split(',') + + for booking_id in booking_ids: + booking = Booking.query.get(int(booking_id)) + if booking: + booking.payment_status = PaymentStatus.FAILED + booking.stripe_payment_status = payment_intent['status'] + + db.session.commit() + + return jsonify({"success": True}), 200 + +# ============= BUSCAR RESERVA POR NÚMERO DE CONFIRMACIÓN ============= +@api.route('/bookings/search/', methods=['GET']) +def search_booking_by_confirmation(confirmation_number): + """ + Buscar reserva por número de confirmación (público) + Query params: ?email=user@example.com + """ + email = request.args.get('email') + + if not email: + return jsonify({"error": "Email is required"}), 400 + + booking = Booking.query.join(User).filter( + Booking.confirmation_number == confirmation_number.upper(), + User.email == email.lower(), + Booking.status != BookingStatus.CART + ).first() + + if not booking: + return jsonify({"error": "Booking not found"}), 404 + + return jsonify(booking.serialize()), 200 + +# ============= RESERVAS (USUARIO LOGUEADO) ============= +@api.route('/bookings/', methods=['GET']) +@jwt_required() +def get_booking(booking_id): + user_id = get_jwt_identity() + user = User.query.get(user_id) + + booking = Booking.query.get(booking_id) + if not booking: + return jsonify({"error": "Booking not found"}), 404 + + if booking.user_id != user_id and not user.is_admin(): + return jsonify({"error": "Unauthorized"}), 403 + + return jsonify(booking.serialize()), 200 + +@api.route('/bookings/my-bookings', methods=['GET']) +@jwt_required() +def get_my_bookings(): + user_id = get_jwt_identity() + + bookings = Booking.query.filter( + Booking.user_id == user_id, + Booking.status != BookingStatus.CART + ).order_by(Booking.created_at.desc()).all() + + return jsonify([booking.serialize() for booking in bookings]), 200 + +# ============= ADMIN ROUTES ============= +@api.route('/admin/bookings', methods=['GET']) +@admin_required() +def admin_get_all_bookings(): + status = request.args.get('status') + payment_status = request.args.get('payment_status') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + query = Booking.query.filter(Booking.status != BookingStatus.CART) + + if status: + query = query.filter_by(status=BookingStatus[status.upper()]) + + if payment_status: + query = query.filter_by(payment_status=PaymentStatus[payment_status.upper()]) + + if start_date: + start = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(Booking.created_at >= start) + + if end_date: + end = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(Booking.created_at <= end) + + bookings = query.order_by(Booking.created_at.desc()).all() + + return jsonify([booking.serialize_admin() for booking in bookings]), 200 + +@api.route('/admin/bookings/', methods=['PUT']) +@admin_required() +def admin_update_booking(booking_id): + booking = Booking.query.get(booking_id) + if not booking: + return jsonify({"error": "Booking not found"}), 404 + + data = request.get_json() + + if data.get('status'): + booking.status = BookingStatus[data['status'].upper()] + + if data.get('payment_status'): + booking.payment_status = PaymentStatus[data['payment_status'].upper()] + + if data.get('admin_notes'): + booking.admin_notes = data['admin_notes'] + + db.session.commit() + + return jsonify({ + "message": "Booking updated successfully", + "booking": booking.serialize_admin() + }), 200 + +@api.route('/admin/stats', methods=['GET']) +@admin_required() +def admin_get_stats(): + total_bookings = Booking.query.filter(Booking.status != BookingStatus.CART).count() + confirmed_bookings = Booking.query.filter_by(status=BookingStatus.CONFIRMED).count() + pending_bookings = Booking.query.filter_by(status=BookingStatus.PENDING).count() + + total_revenue = db.session.query(func.sum(Booking.total_price)).filter( + Booking.payment_status == PaymentStatus.SUCCEEDED + ).scalar() or 0 + + return jsonify({ + "total_bookings": total_bookings, + "confirmed_bookings": confirmed_bookings, + "pending_bookings": pending_bookings, + "total_revenue": float(total_revenue) + }), 200 @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" + "message": "Hello! I'm a message that came from the backend" } - return jsonify(response_body), 200 + + +@api.route('/test-email', methods=['POST']) +def test_email(): + """Endpoint para probar que el email funciona""" + data = request.get_json() + email = data.get('email') + + if not email: + return jsonify({"error": "Email is required"}), 400 + + html = """ + + +

🎉 Test Email Exitoso

+

Si ves este email, ¡tu configuración SMTP funciona perfectamente!

+

Tu sistema de emails de Celiafarm está listo.

+ + + """ + + try: + from api.email_service import send_email + success = send_email( + recipient=email, + subject="✅ Test Email - Celiafarm", + html_body=html + ) + + if success: + return jsonify({"message": f"Email enviado exitosamente a {email}"}), 200 + else: + return jsonify({"error": "Error al enviar email"}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# Configurar Stripe +stripe.api_key = os.getenv('STRIPE_SECRET_KEY') + +print(f"🔑 STRIPE KEY: {stripe.api_key[:20]}...") + +@api.route('/create-checkout-session', methods=['POST', 'OPTIONS']) +def create_checkout_session(): + # Manejar preflight request + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.json + + # Crear line items para Stripe + line_items = [] + + for item in data['items']: + line_items.append({ + 'price_data': { + 'currency': 'eur', + 'product_data': { + 'name': item['name'], + 'description': f"{item['type'].capitalize()} - CaliaFarm Booking", + }, + 'unit_amount': int(item['subtotal'] * 100), + }, + 'quantity': 1, + }) + + # Obtener URL del frontend + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000') + + # Crear sesión de Stripe Checkout + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=line_items, + mode='payment', + success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/cart", + customer_email=data.get('customer_email'), + metadata={ + 'customer_name': data.get('customer_name', ''), + 'customer_phone': data.get('customer_phone', ''), + 'total_items': str(len(data['items'])) + } + ) + + return jsonify({ + 'checkout_url': checkout_session.url, + 'session_id': checkout_session.id + }), 200 + + except stripe.StripeError as e: + print(f"Stripe error: {str(e)}") + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + print(f"Error: {str(e)}") + return jsonify({'error': str(e)}), 400 + + +@api.route('/verify-payment', methods=['POST']) +def verify_payment(): + try: + data = request.json + session_id = data.get('session_id') + + if not session_id: + return jsonify({'error': 'No session ID provided'}), 400 + + # Verificar si ya existe una reserva con este session_id + existing_booking = Booking.query.filter_by(stripe_payment_intent_id=session_id).first() + if existing_booking: + return jsonify(existing_booking.serialize()), 200 + + # Recuperar la sesión de Stripe + session = stripe.checkout.Session.retrieve(session_id) + + if session.payment_status != 'paid': + return jsonify({'error': 'Payment not completed'}), 400 + + # Generar número de confirmación + confirmation_number = Booking.generate_confirmation_number() + + # Recuperar metadata + metadata = session.metadata + + # Obtener o crear usuario guest + customer_email = session.customer_details.email if hasattr(session, 'customer_details') else session.customer_email + user = User.query.filter_by(email=customer_email).first() + + if not user: + # Crear usuario guest + user = User( + email=customer_email, + name=metadata.get('customer_name', 'Guest'), + phone=metadata.get('customer_phone'), + is_guest=True, + email_verified=False, + password=None # Guest users don't have password + ) + db.session.add(user) + db.session.flush() # Para obtener el user_id + + # Crear la reserva con los campos correctos de tu modelo + booking = Booking( + confirmation_number=confirmation_number, + user_id=user.id, # REQUERIDO según tu modelo + number_of_guests=int(metadata.get('guests', 1)), + total_price=session.amount_total / 100, + payment_status=PaymentStatus.SUCCEEDED, + stripe_payment_intent_id=session_id, # Campo correcto + stripe_payment_status='paid', + status=BookingStatus.CONFIRMED, + special_requests=metadata.get('special_requests') + ) + + db.session.add(booking) + db.session.commit() + + # Preparar datos para el email + booking_data = { + 'booking_number': confirmation_number, + 'customer_email': user.email, + 'customer_name': user.name, + 'customer_phone': user.phone, + 'total_amount': booking.total_price, + 'payment_status': 'paid', + 'created_at': booking.created_at.isoformat(), + 'items': [] + } + + # Enviar email de confirmación + try: + send_booking_confirmation_email(booking_data) + + # Registrar el envío del email + email_log = EmailLog( + booking_id=booking.id, + email_type='booking_confirmation', + recipient_email=user.email, + subject=f'Confirmación de Reserva {confirmation_number}', + status=EmailStatus.SENT, + sent_at=datetime.utcnow() + ) + db.session.add(email_log) + db.session.commit() + except Exception as e: + print(f"Error sending confirmation email: {str(e)}") + # Registrar el error del email + email_log = EmailLog( + booking_id=booking.id, + email_type='booking_confirmation', + recipient_email=user.email, + subject=f'Confirmación de Reserva {confirmation_number}', + status=EmailStatus.FAILED, + error_message=str(e) + ) + db.session.add(email_log) + db.session.commit() + + return jsonify(booking.serialize()), 200 + + except stripe.StripeError as e: + db.session.rollback() + print(f"Stripe error: {str(e)}") + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + db.session.rollback() + print(f"Error verifying payment: {str(e)}") + return jsonify({'error': str(e)}), 400 + + # Manejar preflight request + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.json + + # Crear line items para Stripe + line_items = [] + + for item in data['items']: + line_items.append({ + 'price_data': { + 'currency': 'eur', + 'product_data': { + 'name': item['name'], + 'description': f"{item['type'].capitalize()} - CaliaFarm Booking", + }, + 'unit_amount': int(item['subtotal'] * 100), + }, + 'quantity': 1, + }) + + # Obtener URL del frontend + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000') + + # Crear sesión de Stripe Checkout + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=line_items, + mode='payment', + success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/cart", + customer_email=data.get('customer_email'), + metadata={ + 'customer_name': data.get('customer_name', ''), + 'customer_phone': data.get('customer_phone', ''), + 'total_items': str(len(data['items'])) + } + ) + + return jsonify({ + 'checkout_url': checkout_session.url, + 'session_id': checkout_session.id + }), 200 + + except stripe.StripeError as e: + print(f"Stripe error: {str(e)}") + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + print(f"Error: {str(e)}") + return jsonify({'error': str(e)}), 400 + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.json + + line_items = [] + + for item in data['items']: + line_items.append({ + 'price_data': { + 'currency': 'eur', + 'product_data': { + 'name': item['name'], + 'description': f"{item['type'].capitalize()} - CaliaFarm Booking", + }, + 'unit_amount': int(item['subtotal'] * 100), + }, + 'quantity': 1, + }) + + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000') + + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=line_items, + mode='payment', + success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/cart", + customer_email=data.get('customer_email'), + metadata={ + 'customer_name': data.get('customer_name', ''), + 'customer_phone': data.get('customer_phone', ''), + 'total_items': str(len(data['items'])) + } + ) + + return jsonify({ + 'checkout_url': checkout_session.url, + 'session_id': checkout_session.id + }), 200 + + except stripe.StripeError as e: # 👈 CAMBIAR AQUÍ + print(f"Stripe error: {str(e)}") + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + print(f"Error: {str(e)}") + return jsonify({'error': str(e)}), 400 + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.json + + line_items = [] + + for item in data['items']: + line_items.append({ + 'price_data': { + 'currency': 'eur', + 'product_data': { + 'name': item['name'], + 'description': f"{item['type'].capitalize()} - CaliaFarm Booking", + }, + 'unit_amount': int(item['subtotal'] * 100), + }, + 'quantity': 1, + }) + + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000') + + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=line_items, + mode='payment', + success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/cart", + customer_email=data.get('customer_email'), + metadata={ + 'customer_name': data.get('customer_name', ''), + 'customer_phone': data.get('customer_phone', ''), + 'total_items': str(len(data['items'])) + } + ) + + return jsonify({ + 'checkout_url': checkout_session.url, + 'session_id': checkout_session.id + }), 200 + + except stripe.StripeError as e: # 👈 CAMBIAR AQUÍ + print(f"Stripe error: {str(e)}") + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + print(f"Error: {str(e)}") + return jsonify({'error': str(e)}), 400 + try: + data = request.json + + # Crear line items para Stripe + line_items = [] + + for item in data['items']: + line_items.append({ + 'price_data': { + 'currency': 'eur', + 'product_data': { + 'name': item['name'], + 'description': f"{item['type'].capitalize()} - CaliaFarm Booking", + }, + 'unit_amount': int(item['subtotal'] * 100), # Stripe usa centavos + }, + 'quantity': 1, + }) + + # Obtener URL del frontend + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000') + + # Crear sesión de Stripe Checkout + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=line_items, + mode='payment', + success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/cart", + customer_email=data.get('customer_email'), + metadata={ + 'customer_name': data.get('customer_name', ''), + 'customer_phone': data.get('customer_phone', ''), + 'total_items': str(len(data['items'])) + } + ) + + return jsonify({ + 'checkout_url': checkout_session.url, + 'session_id': checkout_session.id + }), 200 + + except stripe.error.StripeError as e: + print(f"Stripe error: {str(e)}") + return jsonify({'error': f'Stripe error: {str(e)}'}), 400 + except Exception as e: + print(f"Error: {str(e)}") + return jsonify({'error': str(e)}), 400 \ No newline at end of file diff --git a/src/api/seed.py b/src/api/seed.py new file mode 100644 index 0000000000..a8e5fdf663 --- /dev/null +++ b/src/api/seed.py @@ -0,0 +1,215 @@ +# src/api/seed.py +""" +Seed script para poblar la base de datos con datos reales de CaliaFarm +""" +from api.models import ( + db, User, Experience, ExperienceSchedule, Room, Extra, Package, PackageExtra, + UserRole, DayOfWeek, ExtraType +) +from werkzeug.security import generate_password_hash +from datetime import time + +def seed_database(): + """Poblar la base de datos con datos iniciales de CaliaFarm""" + + print("🌱 Iniciando seed de CaliaFarm...") + + # ============= CREAR USUARIO ADMIN ============= + print("👤 Creando usuario admin...") + admin = User.query.filter_by(email='admin@caliafarm.com').first() + if not admin: + admin = User( + email='admin@caliafarm.com', + password=generate_password_hash('admin123'), + name='CaliaFarm Admin', + phone='+39 123 456 7890', + role=UserRole.ADMIN, + is_active=True, + email_verified=True, + is_guest=False + ) + db.session.add(admin) + + # ============= CREAR EXPERIENCIAS ============= + print("🍷 Creando experiencias...") + + # 1. Full Experience + full_exp = Experience.query.filter_by(name='Full Experience: Cooking & Dinner + Wine Tasting').first() + if not full_exp: + full_exp = Experience( + name='Full Experience: Cooking & Dinner + Wine Tasting', + description='Private tours to Sicily\'s finest wineries with exclusive tastings. Learn authentic Sicilian recipes in hands-on cooking classes.', + price=85.0, + max_capacity=8, + duration_hours=6, + image_url='https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=800', + is_active=True + ) + db.session.add(full_exp) + db.session.flush() + + for day in DayOfWeek: + schedule = ExperienceSchedule( + experience_id=full_exp.id, + day_of_week=day, + start_time=time(10, 0) + ) + db.session.add(schedule) + + # 2. Cooking & Dinner + cooking_exp = Experience.query.filter_by(name='Cooking & Dinner Experience').first() + if not cooking_exp: + cooking_exp = Experience( + name='Cooking & Dinner Experience', + description='Visit century-old olive groves and traditional mills to discover Sicily\'s liquid gold.', + price=65.0, + max_capacity=8, + duration_hours=4, + image_url='https://images.unsplash.com/photo-1556910103-1c02745aae4d?w=800', + is_active=True + ) + db.session.add(cooking_exp) + db.session.flush() + + for day in DayOfWeek: + schedule = ExperienceSchedule( + experience_id=cooking_exp.id, + day_of_week=day, + start_time=time(14, 0) + ) + db.session.add(schedule) + + # 3. Wine Tasting + wine_exp = Experience.query.filter_by(name='Sicilian Wine Tasting').first() + if not wine_exp: + wine_exp = Experience( + name='Sicilian Wine Tasting', + description='Learn authentic Sicilian recipes in hands-on cooking classes with local chefs.', + price=75.0, + max_capacity=8, + duration_hours=3, + image_url='https://images.unsplash.com/photo-1506377247377-2a5b3b417ebb?w=800', + is_active=True + ) + db.session.add(wine_exp) + db.session.flush() + + for day in [DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY]: + schedule = ExperienceSchedule( + experience_id=wine_exp.id, + day_of_week=day, + start_time=time(11, 0) + ) + db.session.add(schedule) + + # ============= CREAR HABITACIONES ============= + print("🏨 Creando habitaciones...") + + fico = Room.query.filter_by(name='Fico d\'India').first() + if not fico: + fico = Room( + name='Fico d\'India', + description='Elegant room with panoramic views and private jacuzzi.', + capacity=2, + price_per_night=150.0, + image_url='https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800', + amenities={'jacuzzi': True, 'vineyard_view': True}, + check_in_time=time(15, 0), + check_out_time=time(10, 0), + is_active=True + ) + db.session.add(fico) + + ulivo = Room.query.filter_by(name='Ulivo').first() + if not ulivo: + ulivo = Room( + name='Ulivo', + description='Cozy double room with traditional Sicilian decor.', + capacity=2, + price_per_night=100.0, + image_url='https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=800', + amenities={'olive_grove_view': True}, + check_in_time=time(15, 0), + check_out_time=time(10, 0), + is_active=True + ) + db.session.add(ulivo) + + mandorla = Room.query.filter_by(name='Mandorla').first() + if not mandorla: + mandorla = Room( + name='Mandorla', + description='Bright and spacious double room with garden views.', + capacity=2, + price_per_night=100.0, + image_url='https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800', + amenities={'garden_view': True}, + check_in_time=time(15, 0), + check_out_time=time(10, 0), + is_active=True + ) + db.session.add(mandorla) + + vigna = Room.query.filter_by(name='Vigna').first() + if not vigna: + vigna = Room( + name='Vigna', + description='Spacious triple room perfect for families.', + capacity=3, + price_per_night=140.0, + image_url='https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=800', + amenities={'vineyard_view': True, 'family_friendly': True}, + check_in_time=time(15, 0), + check_out_time=time(10, 0), + is_active=True + ) + db.session.add(vigna) + + # ============= CREAR EXTRAS ============= + print("✨ Creando extras...") + + transfer = Extra.query.filter_by(name='Airport Transfer').first() + if not transfer: + transfer = Extra( + name='Airport Transfer', + description='Private transfer from/to airport', + price=80.0, + type=ExtraType.PER_BOOKING, + is_active=True + ) + db.session.add(transfer) + + breakfast = Extra.query.filter_by(name='Sicilian Breakfast').first() + if not breakfast: + breakfast = Extra( + name='Sicilian Breakfast', + description='Traditional Sicilian breakfast', + price=15.0, + type=ExtraType.PER_GUEST, + is_active=True + ) + db.session.add(breakfast) + + dinner = Extra.query.filter_by(name='Gourmet Dinner').first() + if not dinner: + dinner = Extra( + name='Gourmet Dinner', + description='5-course Sicilian dinner with wine', + price=60.0, + type=ExtraType.PER_GUEST, + is_active=True + ) + db.session.add(dinner) + + db.session.commit() + + print("✅ Seed completado!") + print(f"Experiencias: {Experience.query.count()}") + print(f"Habitaciones: {Room.query.count()}") + print(f"Extras: {Extra.query.count()}") + print("\nAdmin: admin@caliafarm.com / admin123") + +if __name__ == '__main__': + from app import app + with app.app_context(): + seed_database() \ No newline at end of file diff --git a/src/app.py b/src/app.py index 1b3340c0fa..1504005a6e 100644 --- a/src/app.py +++ b/src/app.py @@ -5,13 +5,14 @@ from flask import Flask, request, jsonify, url_for, send_from_directory from flask_migrate import Migrate from flask_swagger import swagger +from flask_jwt_extended import JWTManager +from flask_cors import CORS # 👈 AGREGAR IMPORT from api.utils import APIException, generate_sitemap from api.models import db from api.routes import api from api.admin import setup_admin from api.commands import setup_commands - -# from models import Person +from api.email_service import init_mail ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production" static_file_dir = os.path.join(os.path.dirname( @@ -19,7 +20,16 @@ app = Flask(__name__) app.url_map.strict_slashes = False -# database condiguration +# 👇 CONFIGURAR CORS (AGREGAR ESTO AQUÍ) +CORS(app, resources={ + r"/api/*": { + "origins": "*", + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_headers": ["Content-Type", "Authorization"] + } +}) + +# database configuration db_url = os.getenv("DATABASE_URL") if db_url is not None: app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace( @@ -28,28 +38,40 @@ app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db" app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# JWT CONFIGURATION +app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'dev_secret_key_change_in_production') + +# EMAIL CONFIGURATION (Flask-Mail) +app.config['MAIL_SERVER'] = os.getenv('MAIL_SERVER', 'smtp.gmail.com') +app.config['MAIL_PORT'] = int(os.getenv('MAIL_PORT', 587)) +app.config['MAIL_USE_TLS'] = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true' +app.config['MAIL_USE_SSL'] = os.getenv('MAIL_USE_SSL', 'false').lower() == 'true' +app.config['MAIL_USERNAME'] = os.getenv('MAIL_USERNAME') +app.config['MAIL_PASSWORD'] = os.getenv('MAIL_PASSWORD') +app.config['MAIL_DEFAULT_SENDER'] = os.getenv('MAIL_DEFAULT_SENDER', os.getenv('MAIL_USERNAME')) + +# Initialize extensions MIGRATE = Migrate(app, db, compare_type=True) db.init_app(app) +jwt = JWTManager(app) +init_mail(app) # add the admin setup_admin(app) -# add the admin +# add the commands setup_commands(app) -# Add all endpoints form the API with a "api" prefix +# Add all endpoints from the API with a "api" prefix app.register_blueprint(api, url_prefix='/api') # Handle/serialize errors like a JSON object - - @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code # generate sitemap with all your endpoints - - @app.route('/') def sitemap(): if ENV == "development": @@ -65,8 +87,7 @@ def serve_any_other_file(path): response.cache_control.max_age = 0 # avoid cache memory return response - -# this only runs if `$ python src/main.py` is executed +# this only runs if `$ python src/app.py` is executed if __name__ == '__main__': PORT = int(os.environ.get('PORT', 3001)) - app.run(host='0.0.0.0', port=PORT, debug=True) + app.run(host='0.0.0.0', port=PORT, debug=True) \ No newline at end of file diff --git a/src/front/components/Footer.jsx b/src/front/components/Footer.jsx index f06302dbd2..9c963235fb 100644 --- a/src/front/components/Footer.jsx +++ b/src/front/components/Footer.jsx @@ -1,11 +1,288 @@ -export const Footer = () => ( - -); +// src/front/js/component/Footer.jsx +import React from "react"; +import { Link } from "react-router-dom"; + +export const Footer = () => { + return ( +
+
+
+ {/* Logo y Descripción */} + + + {/* Quick Links */} +
+
Quick Links
+
    +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Home + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Experiences + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Accommodation + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Packages + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + About Us + +
  • +
+
+ + {/* Support */} +
+
Support
+
    +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Contact Us + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + FAQ + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Terms & Conditions + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Privacy Policy + +
  • +
  • + e.target.style.color = '#C9A961'} + onMouseLeave={(e) => e.target.style.color = '#6c757d'} + > + Cancellation Policy + +
  • +
+
+ + {/* Contact Info */} +
+
Contact Info
+ +
+
+ + {/* Bottom Bar */} +
+
+
+

+ © {new Date().getFullYear()} CaliaFarm. All rights reserved. +

+
+
+

+ + + + + + + + 5-Star Rated on TripAdvisor +

+
+
+ + {/* Trust Badges */} +
+
+
+
+ + Secure Payment +
+
+ + SSL Protected +
+
+ + 100% Verified +
+
+ + Family Owned Since 2010 +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 30d43a2636..8220bc1043 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,19 +1,161 @@ -import { Link } from "react-router-dom"; +// src/front/js/component/Navbar.jsx +import React, { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; // 👈 TU HOOK export const Navbar = () => { + const { store, dispatch } = useGlobalReducer(); // 👈 USA TU HOOK + const navigate = useNavigate(); + const [isMenuOpen, setIsMenuOpen] = useState(false); - return ( - - ); + const handleLogout = () => { + dispatch({ type: "logout" }); + navigate('/'); + }; + + return ( + + ); }; \ No newline at end of file diff --git a/src/front/components/SearchBar.jsx b/src/front/components/SearchBar.jsx new file mode 100644 index 0000000000..8c0d4982e1 --- /dev/null +++ b/src/front/components/SearchBar.jsx @@ -0,0 +1,226 @@ +// src/front/js/component/SearchBar.jsx +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export const SearchBar = ({ variant = "hero" }) => { + const navigate = useNavigate(); + const [searchData, setSearchData] = useState({ + checkIn: "", + checkOut: "", + adults: 2, + children: 0 + }); + const [showGuestDropdown, setShowGuestDropdown] = useState(false); + + const handleSearch = (e) => { + e.preventDefault(); + + // Validaciones + if (!searchData.checkIn || !searchData.checkOut) { + alert("Please select check-in and check-out dates"); + return; + } + + const checkInDate = new Date(searchData.checkIn); + const checkOutDate = new Date(searchData.checkOut); + + if (checkInDate >= checkOutDate) { + alert("Check-out date must be after check-in date"); + return; + } + + // Navegar a resultados con query params + const params = new URLSearchParams({ + check_in: searchData.checkIn, + check_out: searchData.checkOut, + guests: searchData.adults + searchData.children + }); + + navigate(`/search?${params.toString()}`); + }; + + const incrementGuests = (type) => { + setSearchData(prev => ({ + ...prev, + [type]: prev[type] + 1 + })); + }; + + const decrementGuests = (type) => { + if (searchData[type] > 0) { + setSearchData(prev => ({ + ...prev, + [type]: prev[type] - 1 + })); + } + }; + + const totalGuests = searchData.adults + searchData.children; + + // Estilos según variante + const isHero = variant === "hero"; + const containerClass = isHero + ? "shadow-lg p-4 rounded-4 bg-white" + : "shadow-sm p-3 rounded-3 bg-white border"; + + return ( +
+
+
+ {/* Check-in Date */} +
+ + setSearchData({ ...searchData, checkIn: e.target.value })} + min={new Date().toISOString().split('T')[0]} + style={{ + borderColor: '#E2E8F0', + fontSize: '1rem' + }} + /> +
+ + {/* Check-out Date */} +
+ + setSearchData({ ...searchData, checkOut: e.target.value })} + min={searchData.checkIn || new Date().toISOString().split('T')[0]} + style={{ + borderColor: '#E2E8F0', + fontSize: '1rem' + }} + /> +
+ + {/* Guests Dropdown */} +
+ +
setShowGuestDropdown(!showGuestDropdown)} + > + {totalGuests} {totalGuests === 1 ? 'Guest' : 'Guests'} + +
+ + {/* Dropdown de huéspedes */} + {showGuestDropdown && ( +
+ {/* Adultos */} +
+
+
Adults
+ Ages 13+ +
+
+ + {searchData.adults} + +
+
+ + {/* Niños */} +
+
+
Children
+ Ages 4-12 +
+
+ + {searchData.children} + +
+
+ +
+ + + Maximum 8 guests per booking + +
+
+ )} +
+ + {/* Search Button */} +
+ +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/hooks/useGlobalReducer.jsx b/src/front/hooks/useGlobalReducer.jsx index 6aeb9d768e..6275411d63 100644 --- a/src/front/hooks/useGlobalReducer.jsx +++ b/src/front/hooks/useGlobalReducer.jsx @@ -1,24 +1,26 @@ -// Import necessary hooks and functions from React. +// src/front/hooks/useGlobalReducer.jsx import { useContext, useReducer, createContext } from "react"; -import storeReducer, { initialStore } from "../store" // Import the reducer and the initial state. +import storeReducer, { initialStore } from "../store.js" // 👈 SIN carpeta store/ -// Create a context to hold the global state of the application -// We will call this global state the "store" to avoid confusion while using local states const StoreContext = createContext() -// Define a provider component that encapsulates the store and warps it in a context provider to -// broadcast the information throught all the app pages and components. export function StoreProvider({ children }) { - // Initialize reducer with the initial state. const [store, dispatch] = useReducer(storeReducer, initialStore()) - // Provide the store and dispatch method to all child components. - return - {children} - + + return ( + + {children} + + ) } -// Custom hook to access the global state and dispatch function. export default function useGlobalReducer() { - const { dispatch, store } = useContext(StoreContext) + const context = useContext(StoreContext) + + if (!context) { + throw new Error('useGlobalReducer must be used within a StoreProvider') + } + + const { dispatch, store } = context return { dispatch, store }; } \ No newline at end of file diff --git a/src/front/pages/Cart.jsx b/src/front/pages/Cart.jsx new file mode 100644 index 0000000000..6e57691b33 --- /dev/null +++ b/src/front/pages/Cart.jsx @@ -0,0 +1,223 @@ +// src/front/pages/Cart.jsx +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + +export const Cart = () => { + const { store, dispatch } = useGlobalReducer(); + const navigate = useNavigate(); + + const removeItem = (index) => { + if (window.confirm("Are you sure you want to remove this item?")) { + dispatch({ type: 'remove_from_cart', payload: index }); + } + }; + + const calculateGrandTotal = () => { + return store.cart.reduce((total, item) => total + item.subtotal, 0).toFixed(2); + }; + + const proceedToCheckout = () => { + if (store.cart.length === 0) { + alert("Your cart is empty"); + return; + } + navigate("/checkout"); + }; + + if (store.cart.length === 0) { + return ( +
+
+
+ +

Your cart is empty

+

+ Looks like you haven't added anything to your cart yet. +

+ + Start Exploring + +
+
+
+ ); + } + + return ( +
+

Your Cart

+ +
+ {/* Cart Items */} +
+ {store.cart.map((item, index) => ( +
+
+
+ {/* Image */} +
+ {item.name} +
+ + {/* Details */} +
+
{item.name}
+ + {/* Experience Details */} + {item.type === 'experience' && ( + <> +

+ + Date: {new Date(item.date).toLocaleDateString()} +

+

+ + Guests: {item.guests} +

+

+ Price: €{item.price}/person +

+ + )} + + {/* Room Details */} + {item.type === 'room' && ( + <> +

+ + Check-in: {new Date(item.check_in).toLocaleDateString()} +

+

+ + Check-out: {new Date(item.check_out).toLocaleDateString()} +

+

+ + Nights: {item.nights} +

+

+ Price: €{item.price}/night +

+ + )} + + {/* Extras */} + {item.extras && item.extras.length > 0 && ( +
+ + Extras: + +
    + {item.extras.map((extra, idx) => ( +
  • {extra.name} - €{extra.price}
  • + ))} +
+
+ )} +
+ + {/* Price and Remove */} +
+

+ €{item.subtotal.toFixed(2)} +

+ +
+
+
+
+ ))} +
+ + {/* Order Summary */} +
+
+
+

Order Summary

+ + {/* Items breakdown */} +
+ {store.cart.map((item, index) => ( +
+ {item.name} + €{item.subtotal.toFixed(2)} +
+ ))} +
+ +
+ + {/* Total Items */} +
+ Total Items: + {store.cart.length} +
+ +
+ + {/* Grand Total */} +
+
Grand Total:
+

+ €{calculateGrandTotal()} +

+
+ + {/* Checkout Button */} + + + {/* Continue Shopping */} + + Continue Shopping + + + {/* Security badges */} +
+ + + Secure Checkout + + + + Your payment information is safe + +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Checkout.jsx b/src/front/pages/Checkout.jsx new file mode 100644 index 0000000000..9f60fa404f --- /dev/null +++ b/src/front/pages/Checkout.jsx @@ -0,0 +1,361 @@ +// src/front/pages/Checkout.jsx +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + +export const Checkout = () => { + const { store, dispatch } = useGlobalReducer(); + const navigate = useNavigate(); + + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + email: "", + name: "", + phone: "", + country: "Italy", + address: "", + city: "", + postal_code: "", + special_requests: "" + }); + + useEffect(() => { + // Si el carrito está vacío, redirigir + if (store.cart.length === 0) { + navigate("/cart"); + } + }, [store.cart, navigate]); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData({ + ...formData, + [name]: value + }); + }; + + const calculateGrandTotal = () => { + return store.cart.reduce((total, item) => total + item.subtotal, 0).toFixed(2); + }; + + const validateForm = () => { + if (!formData.email || !formData.name || !formData.phone) { + alert("Please fill in all required fields"); + return false; + } + + // Validar email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.email)) { + alert("Please enter a valid email address"); + return false; + } + + return true; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) return; + + setLoading(true); + + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + + // Preparar datos de la reserva + const bookingData = { + customer_email: formData.email, + customer_name: formData.name, + customer_phone: formData.phone, + country: formData.country, + address: formData.address, + city: formData.city, + postal_code: formData.postal_code, + special_requests: formData.special_requests, + items: store.cart.map(item => ({ + type: item.type, + id: item.id, + name: item.name, + date: item.date || null, + check_in: item.check_in || null, + check_out: item.check_out || null, + guests: item.guests || item.capacity, + nights: item.nights || null, + price: item.price, + extras: item.extras, + subtotal: item.subtotal + })), + total_amount: parseFloat(calculateGrandTotal()) + }; + + // Crear sesión de checkout con Stripe + const response = await fetch(`${backendUrl}/api/create-checkout-session`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(bookingData) + }); + + const data = await response.json(); + + if (response.ok) { + // Redirigir a Stripe Checkout + window.location.href = data.checkout_url; + } else { + alert(data.error || "Error creating checkout session"); + setLoading(false); + } + } catch (error) { + console.error("Error:", error); + alert("An error occurred. Please try again."); + setLoading(false); + } + }; + + if (store.cart.length === 0) { + return null; + } + + return ( +
+

Checkout

+ +
+ {/* Left Column - Form */} +
+
+ {/* Contact Information */} +
+
+

Contact Information

+ +
+ + + + Confirmation will be sent to this email + +
+ +
+
+ + +
+
+ + +
+
+
+
+ + {/* Billing Address */} +
+
+

Billing Address

+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+ + {/* Special Requests */} +
+
+

Special Requests

+ +
+
+
+
+ + {/* Right Column - Order Summary */} +
+
+
+

Order Summary

+ + {/* Cart Items */} +
+ {store.cart.map((item, index) => ( +
+
+ {item.name} +
+
{item.name}
+ + {item.type === 'experience' && ( + <> + {new Date(item.date).toLocaleDateString()} · {item.guests} guests + + )} + {item.type === 'room' && ( + <> + {item.nights} nights · {new Date(item.check_in).toLocaleDateString()} + + )} + +
+ + €{item.subtotal.toFixed(2)} + +
+
+
+
+ ))} +
+ +
+ + {/* Total */} +
+
Total:
+

+ €{calculateGrandTotal()} +

+
+ + {/* Payment Button */} + + + {/* Security Info */} +
+ + + Secure payment powered by Stripe + + + + Your payment information is encrypted + +
+ + {/* Cancellation Policy */} +
+
Cancellation Policy
+ + Free cancellation up to 48 hours before your booking. + After that, cancellations are subject to a 50% fee. + +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Confirmation.jsx b/src/front/pages/Confirmation.jsx new file mode 100644 index 0000000000..b774b24030 --- /dev/null +++ b/src/front/pages/Confirmation.jsx @@ -0,0 +1,270 @@ +// src/front/pages/Confirmation.jsx +import React, { useState, useEffect } from "react"; +import { useSearchParams, Link } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + +export const Confirmation = () => { + const [searchParams] = useSearchParams(); + const { dispatch } = useGlobalReducer(); + const [loading, setLoading] = useState(true); + const [booking, setBooking] = useState(null); + const [error, setError] = useState(null); + + const sessionId = searchParams.get('session_id'); + + useEffect(() => { + if (sessionId) { + verifyPayment(); + } else { + setError("No session ID found"); + setLoading(false); + } + }, [sessionId]); + + const verifyPayment = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + const response = await fetch(`${backendUrl}/api/verify-payment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ session_id: sessionId }) + }); + + const data = await response.json(); + + if (response.ok) { + setBooking(data); + // Limpiar carrito después del pago exitoso + dispatch({ type: 'clear_cart' }); + } else { + setError(data.error || "Payment verification failed"); + } + + setLoading(false); + } catch (error) { + console.error("Error verifying payment:", error); + setError("An error occurred while verifying your payment"); + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+ Loading... +
+

Verifying your payment...

+

Please wait while we confirm your booking.

+
+ ); + } + + if (error) { + return ( +
+
+
+ +

Payment Verification Failed

+

{error}

+ + Return to Home + +
+
+
+ ); + } + + return ( +
+
+
+ {/* Success Header */} +
+
+ +
+

Booking Confirmed!

+

+ Thank you for your booking. A confirmation email has been sent to{' '} + {booking?.customer_email} +

+
+ + {/* Booking Details Card */} +
+
+
+
+

Booking Reference

+

+ #{booking?.booking_number} +

+
+
+ Booking Date + {new Date(booking?.created_at).toLocaleDateString()} +
+
+ + {/* Customer Information */} +
+
Customer Information
+
+
+ Name + {booking?.customer_name} +
+
+ Email + {booking?.customer_email} +
+
+ Phone + {booking?.customer_phone} +
+
+ Payment Status + Paid +
+
+
+ + {/* Booking Items */} +
+
Booking Details
+ {booking?.items?.map((item, index) => ( +
+
+ {item.name} +
+
{item.name}
+
+ {item.type === 'experience' && ( + <> +
+ + Date: {new Date(item.date).toLocaleDateString()} +
+
+ + Guests: {item.guests} +
+ + )} + {item.type === 'room' && ( + <> +
+ + Check-in: {new Date(item.check_in).toLocaleDateString()} +
+
+ + Check-out: {new Date(item.check_out).toLocaleDateString()} +
+
+ + Nights: {item.nights} +
+ + )} +
+ {item.extras && item.extras.length > 0 && ( +
+ + Extras: {item.extras.map(e => e.name).join(', ')} + +
+ )} +
+
+ + €{item.subtotal.toFixed(2)} + +
+
+
+ ))} +
+ + {/* Total */} +
+
Total Paid
+

+ €{booking?.total_amount?.toFixed(2)} +

+
+
+
+ + {/* Important Information */} +
+
+
+ + Important Information +
+
    +
  • + A confirmation email with your booking details has been sent to your email address. +
  • +
  • + Please arrive 15 minutes before your scheduled time. +
  • +
  • + For any changes or cancellations, please contact us at least 48 hours in advance. +
  • +
  • + If you have any questions, contact us at: + info@caliafarm.com +
  • +
+
+
+ + {/* Actions */} +
+ + Return to Home + + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/ExperienceDetail.jsx b/src/front/pages/ExperienceDetail.jsx new file mode 100644 index 0000000000..409b65037d --- /dev/null +++ b/src/front/pages/ExperienceDetail.jsx @@ -0,0 +1,298 @@ +// src/front/pages/ExperienceDetail.jsx +import React, { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + +export const ExperienceDetail = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const { store, dispatch } = useGlobalReducer(); + + const [experience, setExperience] = useState(null); + const [extras, setExtras] = useState([]); + const [loading, setLoading] = useState(true); + + // Formulario + const [selectedDate, setSelectedDate] = useState(""); + const [guests, setGuests] = useState(2); + const [selectedExtras, setSelectedExtras] = useState([]); + + useEffect(() => { + loadExperience(); + loadExtras(); + }, [id]); + + const loadExperience = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + const response = await fetch(`${backendUrl}/api/experiences/${id}`); + const data = await response.json(); + + if (response.ok) { + setExperience(data); + } else { + alert("Experience not found"); + navigate("/"); + } + setLoading(false); + } catch (error) { + console.error("Error loading experience:", error); + setLoading(false); + } + }; + + const loadExtras = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + const response = await fetch(`${backendUrl}/api/extras`); + const data = await response.json(); + if (response.ok) setExtras(data); + } catch (error) { + console.error("Error loading extras:", error); + } + }; + + const toggleExtra = (extra) => { + const exists = selectedExtras.find(e => e.id === extra.id); + if (exists) { + setSelectedExtras(selectedExtras.filter(e => e.id !== extra.id)); + } else { + setSelectedExtras([...selectedExtras, extra]); + } + }; + + const calculateTotal = () => { + if (!experience) return 0; + + let total = experience.price * guests; + + selectedExtras.forEach(extra => { + if (extra.type === 'per_guest') { + total += extra.price * guests; + } else { + total += extra.price; + } + }); + + return total.toFixed(2); + }; + + const addToCart = () => { + if (!selectedDate) { + alert("Please select a date"); + return; + } + + const cartItem = { + type: 'experience', + id: experience.id, + name: experience.name, + date: selectedDate, + guests: guests, + price: experience.price, + image_url: experience.image_url, + extras: selectedExtras, + subtotal: parseFloat(calculateTotal()) + }; + + dispatch({ type: 'add_to_cart', payload: cartItem }); + alert("Added to cart!"); + navigate("/cart"); + }; + + if (loading) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (!experience) { + return ( +
+

Experience not found

+
+ ); + } + + return ( +
+
+ {/* Left Column - Image and Description */} +
+ {experience.name} + +

{experience.name}

+ +
+
+ + {experience.duration_hours} hours +
+
+ + Max {experience.max_capacity} guests +
+
+ + 5.0 (120 reviews) +
+
+ +
+

About this experience

+

+ {experience.description} +

+
+ +
+

What's included

+
    +
  • + + Expert guide +
  • +
  • + + All materials and equipment +
  • +
  • + + Light refreshments +
  • +
  • + + Transportation during activity +
  • +
+
+
+ + {/* Right Column - Booking Form */} +
+
+
+
+
+

+ €{experience.price} +

+ per person +
+
+ + {/* Date Selection */} +
+ + setSelectedDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + /> +
+ + {/* Guests Selection */} +
+ +
+ + {guests} + +
+
+ + {/* Extras */} + {extras.length > 0 && ( +
+ + {extras.map(extra => ( +
+ e.id === extra.id)} + onChange={() => toggleExtra(extra)} + /> + +
+ ))} +
+ )} + + {/* Price Breakdown */} +
+
+ €{experience.price} x {guests} guests + €{(experience.price * guests).toFixed(2)} +
+ {selectedExtras.map(extra => ( +
+ {extra.name} + + €{extra.type === 'per_guest' + ? (extra.price * guests).toFixed(2) + : extra.price.toFixed(2) + } + +
+ ))} +
+ Total + €{calculateTotal()} +
+
+ + {/* Add to Cart Button */} + + +

+ You won't be charged yet +

+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx index 341ed21768..a95a7107be 100644 --- a/src/front/pages/Home.jsx +++ b/src/front/pages/Home.jsx @@ -1,52 +1,223 @@ -import React, { useEffect } from "react" -import rigoImageUrl from "../assets/img/rigo-baby.jpg"; +// src/front/pages/Home.jsx +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; +import { SearchBar } from "../components/SearchBar.jsx"; export const Home = () => { + const { store, dispatch } = useGlobalReducer(); + const [loading, setLoading] = useState(true); - const { store, dispatch } = useGlobalReducer() - - const loadMessage = async () => { - try { - const backendUrl = import.meta.env.VITE_BACKEND_URL - - if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file") - - const response = await fetch(backendUrl + "/api/hello") - const data = await response.json() - - if (response.ok) dispatch({ type: "set_hello", payload: data.message }) - - return data - - } catch (error) { - if (error.message) throw new Error( - `Could not fetch the message from the backend. - Please check if the backend is running and the backend port is public.` - ); - } - - } - - useEffect(() => { - loadMessage() - }, []) - - return ( -
-

Hello Rigo!!

-

- Rigo Baby -

-
- {store.message ? ( - {store.message} - ) : ( - - Loading message from the backend (make sure your python 🐍 backend is running)... - - )} -
-
- ); -}; \ No newline at end of file + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + + // Cargar experiencias + const expResponse = await fetch(`${backendUrl}/api/experiences`); + const expData = await expResponse.json(); + if (expResponse.ok) { + dispatch({ type: "set_experiences", payload: expData }); + } + + // Cargar habitaciones + const roomResponse = await fetch(`${backendUrl}/api/rooms`); + const roomData = await roomResponse.json(); + if (roomResponse.ok) { + dispatch({ type: "set_rooms", payload: roomData }); + } + + setLoading(false); + } catch (error) { + console.error("Error loading data:", error); + setLoading(false); + } + }; + + const experiences = store.experiences.slice(0, 3); + + return ( + <> + {/* HERO SECTION */} +
+
+
+
+
+ +
+ +

+ CALIAFARM +

+ +
+ +

+ Authentic Sicilian Agritourism +

+ +

+ Experience the true essence of Sicily through exclusive wine tours, + olive oil tastings, and luxury accommodation in the heart of the countryside +

+ +
+
+ + Between Palermo & Trapani +
+
+ + Small Groups · Max 8 People +
+
+ + 5-Star Rated Experiences +
+
+ +
+ +
+
+
+
+
+ + {/* STATS SECTION */} +
+
+
+
+

500+

+

Happy Guests

+
+
+

50+

+

Wine Partners

+
+
+

15

+

Years Experience

+
+
+

100%

+

Sicilian Authentic

+
+
+
+
+ + {/* EXPERIENCES SECTION */} +
+
+
+ + What We Offer + +

+ Sicilian Experiences +

+

+ Immerse yourself in authentic Sicilian culture through our curated experiences +

+
+ + {loading ? ( +
+
+ Loading... +
+
+ ) : ( +
+ {experiences.length > 0 ? ( + experiences.map((exp) => ( +
+
e.currentTarget.style.transform = 'translateY(-10px)'} + onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'} + > +
+ {exp.name} +
+ €{exp.price}/person +
+
+
+
+ {exp.name} +
+

+ {exp.description.substring(0, 120)}... +

+
+ + + {exp.duration_hours}h + + + + Max {exp.max_capacity} + +
+ + View Details + +
+
+
+ )) + ) : ( +
+

No experiences available at the moment.

+
+ )} +
+ )} +
+
+ + ); +}; \ No newline at end of file diff --git a/src/front/pages/RoomDetail.jsx b/src/front/pages/RoomDetail.jsx new file mode 100644 index 0000000000..12bca145a6 --- /dev/null +++ b/src/front/pages/RoomDetail.jsx @@ -0,0 +1,341 @@ +// src/front/pages/RoomDetail.jsx +import React, { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + +export const RoomDetail = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const { store, dispatch } = useGlobalReducer(); + + const [room, setRoom] = useState(null); + const [extras, setExtras] = useState([]); + const [loading, setLoading] = useState(true); + + // Formulario + const [checkIn, setCheckIn] = useState(""); + const [checkOut, setCheckOut] = useState(""); + const [selectedExtras, setSelectedExtras] = useState([]); + + useEffect(() => { + loadRoom(); + loadExtras(); + }, [id]); + + const loadRoom = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + const response = await fetch(`${backendUrl}/api/rooms/${id}`); + const data = await response.json(); + + if (response.ok) { + setRoom(data); + } else { + alert("Room not found"); + navigate("/"); + } + setLoading(false); + } catch (error) { + console.error("Error loading room:", error); + setLoading(false); + } + }; + + const loadExtras = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + const response = await fetch(`${backendUrl}/api/extras`); + const data = await response.json(); + if (response.ok) setExtras(data); + } catch (error) { + console.error("Error loading extras:", error); + } + }; + + const toggleExtra = (extra) => { + const exists = selectedExtras.find(e => e.id === extra.id); + if (exists) { + setSelectedExtras(selectedExtras.filter(e => e.id !== extra.id)); + } else { + setSelectedExtras([...selectedExtras, extra]); + } + }; + + const calculateNights = () => { + if (!checkIn || !checkOut) return 0; + const start = new Date(checkIn); + const end = new Date(checkOut); + const diffTime = Math.abs(end - start); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays; + }; + + const calculateTotal = () => { + if (!room) return 0; + + const nights = calculateNights(); + let total = room.price_per_night * nights; + + selectedExtras.forEach(extra => { + if (extra.type === 'per_guest') { + total += extra.price * room.capacity * nights; + } else { + total += extra.price; + } + }); + + return total.toFixed(2); + }; + + const addToCart = () => { + if (!checkIn || !checkOut) { + alert("Please select check-in and check-out dates"); + return; + } + + if (new Date(checkIn) >= new Date(checkOut)) { + alert("Check-out date must be after check-in date"); + return; + } + + const nights = calculateNights(); + + const cartItem = { + type: 'room', + id: room.id, + name: room.name, + check_in: checkIn, + check_out: checkOut, + nights: nights, + guests: room.capacity, + price: room.price_per_night, + image_url: room.image_url, + extras: selectedExtras, + subtotal: parseFloat(calculateTotal()) + }; + + dispatch({ type: 'add_to_cart', payload: cartItem }); + alert("Added to cart!"); + navigate("/cart"); + }; + + if (loading) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (!room) { + return ( +
+

Room not found

+
+ ); + } + + const nights = calculateNights(); + + return ( +
+
+ {/* Left Column - Image and Description */} +
+ {room.name} + +

{room.name}

+ +
+
+ + Up to {room.capacity} guests +
+
+ + Luxury room +
+
+ + 5.0 (85 reviews) +
+
+ +
+

About this room

+

+ {room.description} +

+
+ + {/* Amenities */} +
+

Amenities

+
+ {room.amenities && Object.entries(room.amenities).map(([key, value]) => ( + value && ( +
+
+ + + {key.replace(/_/g, ' ')} + +
+
+ ) + ))} +
+
+ + {/* Check-in/out times */} +
+

House Rules

+
+
+
+ +
+ Check-in: + After 3:00 PM +
+
+
+
+
+ +
+ Check-out: + Before 10:00 AM +
+
+
+
+
+
+ + {/* Right Column - Booking Form */} +
+
+
+
+
+

+ €{room.price_per_night} +

+ per night +
+
+ + {/* Check-in Date */} +
+ + setCheckIn(e.target.value)} + min={new Date().toISOString().split('T')[0]} + /> +
+ + {/* Check-out Date */} +
+ + setCheckOut(e.target.value)} + min={checkIn || new Date().toISOString().split('T')[0]} + /> +
+ + {/* Show nights if dates selected */} + {nights > 0 && ( +
+ + {nights} {nights === 1 ? 'night' : 'nights'} +
+ )} + + {/* Extras */} + {extras.length > 0 && ( +
+ + {extras.map(extra => ( +
+ e.id === extra.id)} + onChange={() => toggleExtra(extra)} + /> + +
+ ))} +
+ )} + + {/* Price Breakdown */} + {nights > 0 && ( +
+
+ €{room.price_per_night} x {nights} nights + €{(room.price_per_night * nights).toFixed(2)} +
+ {selectedExtras.map(extra => ( +
+ {extra.name} + + €{extra.type === 'per_guest' + ? (extra.price * room.capacity * nights).toFixed(2) + : extra.price.toFixed(2) + } + +
+ ))} +
+ Total + €{calculateTotal()} +
+
+ )} + + {/* Add to Cart Button */} + + +

+ You won't be charged yet +

+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/SearchResults.jsx b/src/front/pages/SearchResults.jsx new file mode 100644 index 0000000000..b31e5cc4b4 --- /dev/null +++ b/src/front/pages/SearchResults.jsx @@ -0,0 +1,166 @@ +// src/front/pages/SearchResults.jsx +import React, { useState, useEffect } from "react"; +import { useSearchParams, Link } from "react-router-dom"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + +export const SearchResults = () => { + const [searchParams] = useSearchParams(); + const { store } = useGlobalReducer(); + const [loading, setLoading] = useState(true); + const [results, setResults] = useState({ experiences: [], rooms: [] }); + + // Obtener parámetros de búsqueda + const checkIn = searchParams.get('check_in'); + const checkOut = searchParams.get('check_out'); + const guests = searchParams.get('guests'); + + useEffect(() => { + searchAvailability(); + }, [checkIn, checkOut, guests]); + + const searchAvailability = async () => { + try { + const backendUrl = import.meta.env.VITE_BACKEND_URL; + + // Por ahora mostramos todo (luego implementaremos filtrado por disponibilidad) + const expResponse = await fetch(`${backendUrl}/api/experiences`); + const expData = await expResponse.json(); + + const roomResponse = await fetch(`${backendUrl}/api/rooms`); + const roomData = await roomResponse.json(); + + if (expResponse.ok && roomResponse.ok) { + setResults({ + experiences: expData, + rooms: roomData + }); + } + + setLoading(false); + } catch (error) { + console.error("Error searching:", error); + setLoading(false); + } + }; + + return ( +
+ {/* SEARCH SUMMARY */} +
+
+

Search Results

+
+
+ Check-in: +

{checkIn || 'Not selected'}

+
+
+ Check-out: +

{checkOut || 'Not selected'}

+
+
+ Guests: +

{guests || '0'}

+
+
+
+
+ + {loading ? ( +
+
+ Loading... +
+
+ ) : ( + <> + {/* EXPERIENCES */} +
+

Experiences ({results.experiences.length})

+
+ {results.experiences.map((exp) => ( +
+
+ {exp.name} +
+
{exp.name}
+

+ {exp.description.substring(0, 100)}... +

+
+ €{exp.price}/person + + + {exp.duration_hours}h + +
+ + View Details + +
+
+
+ ))} +
+
+ + {/* ROOMS */} +
+

Accommodation ({results.rooms.length})

+
+ {results.rooms.map((room) => ( +
+
+ {room.name} +
+
{room.name}
+

+ {room.description.substring(0, 100)}... +

+
+ €{room.price_per_night}/night + + + {room.capacity} guests + +
+ + View Details + +
+
+
+ ))} +
+
+ + )} +
+ ); +}; \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 0557df6141..c17f015b78 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -1,5 +1,4 @@ -// Import necessary components and functions from react-router-dom. - +// src/front/routes.jsx import { createBrowserRouter, createRoutesFromElements, @@ -9,22 +8,39 @@ import { Layout } from "./pages/Layout"; import { Home } from "./pages/Home"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; +import { SearchResults } from "./pages/SearchResults"; +import { ExperienceDetail } from "./pages/ExperienceDetail"; +import { RoomDetail } from "./pages/RoomDetail"; +import { Cart } from "./pages/Cart"; +import { Checkout } from "./pages/Checkout"; +import { Confirmation } from "./pages/Confirmation"; // 👈 NUEVO export const router = createBrowserRouter( createRoutesFromElements( - // CreateRoutesFromElements function allows you to build route elements declaratively. - // Create your routes here, if you want to keep the Navbar and Footer in all views, add your new routes inside the containing Route. - // Root, on the contrary, create a sister Route, if you have doubts, try it! - // Note: keep in mind that errorElement will be the default page when you don't get a route, customize that page to make your project more attractive. - // Note: The child paths of the Layout element replace the Outlet component with the elements contained in the "element" attribute of these child paths. - - // Root Route: All navigation will start from here. - } errorElement={

Not found!

} > - - {/* Nested Routes: Defines sub-routes within the BaseHome component. */} - } /> - } /> {/* Dynamic route for single items */} - } /> - - ) + } errorElement={

Not found!

} > + {/* Main Routes */} + } /> + } /> + + {/* Detail Pages */} + } /> + } /> + + {/* Booking Flow */} + } /> + } /> + } /> {/* 👈 NUEVO */} + + {/* Other Pages */} + } /> + } /> + + ), + { + basename: import.meta.env.VITE_BASENAME || "", + future: { + v7_startTransition: true, + v7_relativeSplatPath: true + } + } ); \ No newline at end of file diff --git a/src/front/store.js b/src/front/store.js index 3062cd222d..590840431e 100644 --- a/src/front/store.js +++ b/src/front/store.js @@ -1,38 +1,120 @@ -export const initialStore=()=>{ - return{ - message: null, - todos: [ - { - id: 1, - title: "Make the bed", - background: null, - }, - { - id: 2, - title: "Do my homework", - background: null, - } - ] - } +// src/front/js/store/store.js + +export const initialStore = () => { + return { + // Mensaje de ejemplo (puedes mantenerlo o quitarlo) + message: null, + + // Usuario autenticado + user: null, + + // Carrito de compras + cart: JSON.parse(localStorage.getItem('cart')) || [], + + // Datos de la aplicación + experiences: [], + rooms: [], + packages: [], + + // Reserva en proceso + currentBooking: null + } } export default function storeReducer(store, action = {}) { - switch(action.type){ - case 'set_hello': - return { - ...store, - message: action.payload - }; - - case 'add_task': - - const { id, color } = action.payload - - return { - ...store, - todos: store.todos.map((todo) => (todo.id === id ? { ...todo, background: color } : todo)) - }; - default: - throw Error('Unknown action.'); - } -} + switch (action.type) { + // ============= MENSAJE DE EJEMPLO ============= + case 'set_hello': + return { + ...store, + message: action.payload + }; + + // ============= EXPERIENCIAS ============= + case 'set_experiences': + return { + ...store, + experiences: action.payload + }; + + // ============= HABITACIONES ============= + case 'set_rooms': + return { + ...store, + rooms: action.payload + }; + + // ============= PAQUETES ============= + case 'set_packages': + return { + ...store, + packages: action.payload + }; + + // ============= USUARIO ============= + case 'set_user': + return { + ...store, + user: action.payload + }; + + case 'logout': + localStorage.removeItem('token'); + localStorage.removeItem('cart'); + return { + ...store, + user: null, + cart: [] + }; + + // ============= CARRITO ============= + case 'add_to_cart': + const newCart = [...store.cart, action.payload]; + localStorage.setItem('cart', JSON.stringify(newCart)); + return { + ...store, + cart: newCart + }; + + case 'remove_from_cart': + const filteredCart = store.cart.filter((_, index) => index !== action.payload); + localStorage.setItem('cart', JSON.stringify(filteredCart)); + return { + ...store, + cart: filteredCart + }; + + case 'update_cart_item': + const updatedCart = store.cart.map((item, index) => + index === action.payload.index ? action.payload.item : item + ); + localStorage.setItem('cart', JSON.stringify(updatedCart)); + return { + ...store, + cart: updatedCart + }; + + case 'clear_cart': + localStorage.removeItem('cart'); + return { + ...store, + cart: [] + }; + + // ============= RESERVA ACTUAL ============= + case 'set_current_booking': + return { + ...store, + currentBooking: action.payload + }; + + case 'clear_current_booking': + return { + ...store, + currentBooking: null + }; + + default: + return store; + } +} \ No newline at end of file From cb75e88380c1f3e0f44a7e6bc8371c8727dceda0 Mon Sep 17 00:00:00 2001 From: Lucrecia Parodi Date: Tue, 18 Nov 2025 17:43:08 +0000 Subject: [PATCH 2/2] Fix migration system - clean rebuild --- migrations/versions/0763d677d453_.py | 35 -- migrations/versions/377f79565771_.py | 76 ---- migrations/versions/5c9f71906a93_.py | 46 --- ...py => c878237aa65e_fix_table_structure.py} | 86 ++++- migrations/versions/c92a78fd1a22_.py | 67 ---- src/api/models.py | 363 +++++++++++------- 6 files changed, 293 insertions(+), 380 deletions(-) delete mode 100644 migrations/versions/0763d677d453_.py delete mode 100644 migrations/versions/377f79565771_.py delete mode 100644 migrations/versions/5c9f71906a93_.py rename migrations/versions/{4605fa1cbe47_.py => c878237aa65e_fix_table_structure.py} (71%) delete mode 100644 migrations/versions/c92a78fd1a22_.py 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/377f79565771_.py b/migrations/versions/377f79565771_.py deleted file mode 100644 index f85dc04226..0000000000 --- a/migrations/versions/377f79565771_.py +++ /dev/null @@ -1,76 +0,0 @@ -"""empty message - -Revision ID: 377f79565771 -Revises: 5c9f71906a93 -Create Date: 2025-11-16 15:20:11.850371 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '377f79565771' -down_revision = '5c9f71906a93' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('email_logs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('booking_id', sa.Integer(), nullable=False), - sa.Column('email_type', sa.String(length=50), nullable=False), - sa.Column('recipient_email', sa.String(length=120), nullable=False), - sa.Column('subject', sa.String(length=200), nullable=False), - sa.Column('status', sa.Enum('PENDING', 'SENT', 'FAILED', name='emailstatus'), nullable=False), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('sent_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('bookings', schema=None) as batch_op: - batch_op.add_column(sa.Column('confirmation_number', sa.String(length=20), nullable=False)) - batch_op.create_index(batch_op.f('ix_bookings_confirmation_number'), ['confirmation_number'], unique=True) - - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=False)) - batch_op.add_column(sa.Column('verification_token', sa.String(length=100), nullable=True)) - batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True)) - batch_op.add_column(sa.Column('password_reset_token', sa.String(length=100), nullable=True)) - batch_op.add_column(sa.Column('password_reset_expires', sa.DateTime(), nullable=True)) - batch_op.add_column(sa.Column('is_guest', sa.Boolean(), nullable=False)) - batch_op.add_column(sa.Column('last_login', sa.DateTime(), nullable=True)) - batch_op.alter_column('password', - existing_type=sa.VARCHAR(length=255), - nullable=True) - batch_op.create_unique_constraint(None, ['verification_token']) - batch_op.create_unique_constraint(None, ['password_reset_token']) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='unique') - batch_op.drop_constraint(None, type_='unique') - batch_op.alter_column('password', - existing_type=sa.VARCHAR(length=255), - nullable=False) - batch_op.drop_column('last_login') - batch_op.drop_column('is_guest') - batch_op.drop_column('password_reset_expires') - batch_op.drop_column('password_reset_token') - batch_op.drop_column('verification_token_expires') - batch_op.drop_column('verification_token') - batch_op.drop_column('email_verified') - - with op.batch_alter_table('bookings', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_bookings_confirmation_number')) - batch_op.drop_column('confirmation_number') - - op.drop_table('email_logs') - # ### end Alembic commands ### diff --git a/migrations/versions/5c9f71906a93_.py b/migrations/versions/5c9f71906a93_.py deleted file mode 100644 index a4a3abe400..0000000000 --- a/migrations/versions/5c9f71906a93_.py +++ /dev/null @@ -1,46 +0,0 @@ -"""empty message - -Revision ID: 5c9f71906a93 -Revises: 4605fa1cbe47 -Create Date: 2025-11-16 15:14:33.202794 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '5c9f71906a93' -down_revision = '4605fa1cbe47' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('bookings', schema=None) as batch_op: - batch_op.add_column(sa.Column('experience_time', sa.Time(), nullable=True)) - batch_op.add_column(sa.Column('check_in_time', sa.Time(), nullable=True)) - batch_op.add_column(sa.Column('check_out_time', sa.Time(), nullable=True)) - batch_op.add_column(sa.Column('cart_expires_at', sa.DateTime(), nullable=True)) - - with op.batch_alter_table('rooms', schema=None) as batch_op: - batch_op.add_column(sa.Column('check_in_time', sa.Time(), nullable=False)) - batch_op.add_column(sa.Column('check_out_time', sa.Time(), nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('rooms', schema=None) as batch_op: - batch_op.drop_column('check_out_time') - batch_op.drop_column('check_in_time') - - with op.batch_alter_table('bookings', schema=None) as batch_op: - batch_op.drop_column('cart_expires_at') - batch_op.drop_column('check_out_time') - batch_op.drop_column('check_in_time') - batch_op.drop_column('experience_time') - - # ### end Alembic commands ### diff --git a/migrations/versions/4605fa1cbe47_.py b/migrations/versions/c878237aa65e_fix_table_structure.py similarity index 71% rename from migrations/versions/4605fa1cbe47_.py rename to migrations/versions/c878237aa65e_fix_table_structure.py index 31917ea96b..a15ed957b9 100644 --- a/migrations/versions/4605fa1cbe47_.py +++ b/migrations/versions/c878237aa65e_fix_table_structure.py @@ -1,8 +1,8 @@ -"""empty message +"""Fix table structure -Revision ID: 4605fa1cbe47 -Revises: 0763d677d453 -Create Date: 2025-11-16 15:09:18.218494 +Revision ID: c878237aa65e +Revises: +Create Date: 2025-11-18 17:40:26.827531 """ from alembic import op @@ -10,8 +10,8 @@ # revision identifiers, used by Alembic. -revision = '4605fa1cbe47' -down_revision = '0763d677d453' +revision = 'c878237aa65e' +down_revision = None branch_labels = None depends_on = None @@ -50,19 +50,30 @@ def upgrade(): sa.Column('image_url', sa.String(length=500), nullable=True), sa.Column('amenities', sa.JSON(), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('check_in_time', sa.Time(), nullable=False), + sa.Column('check_out_time', sa.Time(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('email', sa.String(length=120), nullable=False), - sa.Column('password', sa.String(length=255), nullable=False), + sa.Column('password', sa.String(length=255), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('role', sa.Enum('USER', 'ADMIN', name='userrole'), nullable=False), sa.Column('name', sa.String(length=100), nullable=True), sa.Column('phone', sa.String(length=20), nullable=True), + sa.Column('email_verified', sa.Boolean(), nullable=False), + sa.Column('verification_token', sa.String(length=100), nullable=True), + sa.Column('verification_token_expires', sa.DateTime(), nullable=True), + sa.Column('password_reset_token', sa.String(length=100), nullable=True), + sa.Column('password_reset_expires', sa.DateTime(), nullable=True), + sa.Column('is_guest', sa.Boolean(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.Column('last_login', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('password_reset_token'), + sa.UniqueConstraint('verification_token') ) with op.batch_alter_table('users', schema=None) as batch_op: batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True) @@ -117,13 +128,17 @@ def upgrade(): op.create_table('bookings', sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('confirmation_number', sa.String(length=20), nullable=False), sa.Column('experience_id', sa.Integer(), nullable=True), sa.Column('package_id', sa.Integer(), nullable=True), sa.Column('experience_date', sa.Date(), nullable=True), + sa.Column('experience_time', sa.Time(), nullable=True), sa.Column('check_in', sa.Date(), nullable=True), sa.Column('check_out', sa.Date(), nullable=True), + sa.Column('check_in_time', sa.Time(), nullable=True), + sa.Column('check_out_time', sa.Time(), nullable=True), sa.Column('number_of_guests', sa.Integer(), nullable=False), - sa.Column('status', sa.Enum('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED', name='bookingstatus'), nullable=False), + sa.Column('status', sa.Enum('CART', 'PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED', name='bookingstatus'), nullable=False), sa.Column('total_price', sa.Float(), nullable=False), sa.Column('stripe_payment_intent_id', sa.String(length=200), nullable=True), sa.Column('stripe_payment_status', sa.String(length=50), nullable=True), @@ -132,11 +147,15 @@ def upgrade(): sa.Column('admin_notes', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('cart_expires_at', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), sa.ForeignKeyConstraint(['package_id'], ['packages.id'], ), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), sa.PrimaryKeyConstraint('id') ) + with op.batch_alter_table('bookings', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_bookings_confirmation_number'), ['confirmation_number'], unique=True) + op.create_table('package_extras', sa.Column('id', sa.Integer(), nullable=False), sa.Column('package_id', sa.Integer(), nullable=False), @@ -156,6 +175,28 @@ def upgrade(): sa.ForeignKeyConstraint(['extra_id'], ['extras.id'], ), sa.PrimaryKeyConstraint('id') ) + op.create_table('booking_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('item_type', sa.String(length=50), nullable=False), + sa.Column('experience_id', sa.Integer(), nullable=True), + sa.Column('room_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('image_url', sa.String(length=500), nullable=True), + sa.Column('date', sa.Date(), nullable=True), + sa.Column('guests', sa.Integer(), nullable=True), + sa.Column('check_in', sa.Date(), nullable=True), + sa.Column('check_out', sa.Date(), nullable=True), + sa.Column('nights', sa.Integer(), nullable=True), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.Column('subtotal', sa.Float(), nullable=False), + sa.Column('extras', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.PrimaryKeyConstraint('id') + ) op.create_table('booking_rooms', sa.Column('id', sa.Integer(), nullable=False), sa.Column('booking_id', sa.Integer(), nullable=False), @@ -168,23 +209,32 @@ def upgrade(): sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), sa.PrimaryKeyConstraint('id') ) - op.drop_table('user') + op.create_table('email_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=False), + sa.Column('email_type', sa.String(length=50), nullable=False), + sa.Column('recipient_email', sa.String(length=120), nullable=False), + sa.Column('subject', sa.String(length=200), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'SENT', 'FAILED', name='emailstatus'), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.PrimaryKeyConstraint('id') + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('email', sa.VARCHAR(length=120), autoincrement=False, nullable=False), - sa.Column('password', sa.VARCHAR(), autoincrement=False, nullable=False), - sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False), - sa.PrimaryKeyConstraint('id', name=op.f('user_pkey')), - sa.UniqueConstraint('email', name=op.f('user_email_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) - ) + op.drop_table('email_logs') op.drop_table('booking_rooms') + op.drop_table('booking_items') op.drop_table('booking_extras') op.drop_table('package_extras') + with op.batch_alter_table('bookings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_bookings_confirmation_number')) + op.drop_table('bookings') with op.batch_alter_table('room_availability', schema=None) as batch_op: batch_op.drop_index(batch_op.f('ix_room_availability_date')) diff --git a/migrations/versions/c92a78fd1a22_.py b/migrations/versions/c92a78fd1a22_.py deleted file mode 100644 index c2bcb69e49..0000000000 --- a/migrations/versions/c92a78fd1a22_.py +++ /dev/null @@ -1,67 +0,0 @@ -"""empty message - -Revision ID: c92a78fd1a22 -Revises: 377f79565771 -Create Date: 2025-11-16 20:59:23.898397 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'c92a78fd1a22' -down_revision = '377f79565771' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('booking_items', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('experience_id', sa.Integer(), nullable=True), - sa.Column('date', sa.Date(), nullable=True), - sa.Column('available_spots', sa.Integer(), nullable=False), - sa.Column('booking_id', sa.Integer(), nullable=False), - sa.Column('item_type', sa.String(length=50), nullable=False), - sa.Column('room_id', sa.Integer(), nullable=True), - sa.Column('name', sa.String(length=200), nullable=False), - sa.Column('image_url', sa.String(length=500), nullable=True), - sa.Column('guests', sa.Integer(), nullable=True), - sa.Column('check_in', sa.Date(), nullable=True), - sa.Column('check_out', sa.Date(), nullable=True), - sa.Column('nights', sa.Integer(), nullable=True), - sa.Column('unit_price', sa.Float(), nullable=False), - sa.Column('subtotal', sa.Float(), nullable=False), - sa.Column('extras', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), - sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ), - sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('experience_id', 'date', name='_experience_date_uc') - ) - with op.batch_alter_table('experience_availability', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_experience_availability_date')) - - op.drop_table('experience_availability') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('experience_availability', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('experience_id', sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column('date', sa.DATE(), autoincrement=False, nullable=False), - sa.Column('available_spots', sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], name=op.f('experience_availability_experience_id_fkey')), - sa.PrimaryKeyConstraint('id', name=op.f('experience_availability_pkey')), - sa.UniqueConstraint('experience_id', 'date', name=op.f('_experience_date_uc'), postgresql_include=[], postgresql_nulls_not_distinct=False) - ) - with op.batch_alter_table('experience_availability', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_experience_availability_date'), ['date'], unique=False) - - op.drop_table('booking_items') - # ### end Alembic commands ### diff --git a/src/api/models.py b/src/api/models.py index 6cf3771f90..9fc94755df 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -10,10 +10,13 @@ db = SQLAlchemy() # ============= ENUMS ============= + + class UserRole(Enum): USER = "user" ADMIN = "admin" + class BookingStatus(Enum): CART = "cart" PENDING = "pending" @@ -21,6 +24,7 @@ class BookingStatus(Enum): CANCELLED = "cancelled" COMPLETED = "completed" + class DayOfWeek(Enum): MONDAY = "monday" TUESDAY = "tuesday" @@ -30,10 +34,12 @@ class DayOfWeek(Enum): SATURDAY = "saturday" SUNDAY = "sunday" + class ExtraType(Enum): PER_BOOKING = "per_booking" PER_GUEST = "per_guest" + class PaymentStatus(Enum): PENDING = "pending" PROCESSING = "processing" @@ -41,41 +47,57 @@ class PaymentStatus(Enum): FAILED = "failed" REFUNDED = "refunded" + class EmailStatus(Enum): PENDING = "pending" SENT = "sent" FAILED = "failed" # ============= USUARIOS ============= + + class User(db.Model): __tablename__ = 'users' - + id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True) - password: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Nullable para guest checkout - is_active: Mapped[bool] = mapped_column(Boolean(), default=True, nullable=False) - - role: Mapped[UserRole] = mapped_column(SQLEnum(UserRole), default=UserRole.USER, nullable=False) + email: Mapped[str] = mapped_column( + String(120), unique=True, nullable=False, index=True) + password: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True) # Nullable para guest checkout + is_active: Mapped[bool] = mapped_column( + Boolean(), default=True, nullable=False) + + role: Mapped[UserRole] = mapped_column( + SQLEnum(UserRole), default=UserRole.USER, nullable=False) name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) - + # NUEVO: Sistema de verificación de email - email_verified: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) - verification_token: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, unique=True) - verification_token_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - + email_verified: Mapped[bool] = mapped_column( + Boolean(), default=False, nullable=False) + verification_token: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, unique=True) + verification_token_expires: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True) + # NUEVO: Reset de contraseña - password_reset_token: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, unique=True) - password_reset_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - + password_reset_token: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, unique=True) + password_reset_expires: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True) + # NUEVO: Para guest checkout (usuarios sin cuenta) - is_guest: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) - - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - + is_guest: Mapped[bool] = mapped_column( + Boolean(), default=False, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + last_login: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True) + # Relaciones - bookings: Mapped[List["Booking"]] = relationship(back_populates='user', lazy='dynamic') + bookings: Mapped[List["Booking"]] = relationship( + back_populates='user', lazy='dynamic') def serialize(self): return { @@ -90,16 +112,16 @@ def serialize(self): "created_at": self.created_at.isoformat() if self.created_at else None, "last_login": self.last_login.isoformat() if self.last_login else None } - + def is_admin(self): return self.role == UserRole.ADMIN - + def generate_verification_token(self): """Generar token de verificación de email""" self.verification_token = secrets.token_urlsafe(32) self.verification_token_expires = datetime.utcnow() + timedelta(hours=24) return self.verification_token - + def generate_password_reset_token(self): """Generar token de reset de contraseña""" self.password_reset_token = secrets.token_urlsafe(32) @@ -107,21 +129,29 @@ def generate_password_reset_token(self): return self.password_reset_token # ============= EXPERIENCIAS (sin cambios) ============= + + class Experience(db.Model): __tablename__ = 'experiences' - + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(200), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) price: Mapped[float] = mapped_column(Float, nullable=False) - max_capacity: Mapped[int] = mapped_column(Integer, default=20, nullable=False) - duration_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) - image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + max_capacity: Mapped[int] = mapped_column( + Integer, default=20, nullable=False) + duration_hours: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True) + image_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean(), default=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - - schedules: Mapped[List["ExperienceSchedule"]] = relationship(back_populates='experience', cascade='all, delete-orphan') - bookings: Mapped[List["Booking"]] = relationship(back_populates='experience') + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + + schedules: Mapped[List["ExperienceSchedule"]] = relationship( + back_populates='experience', cascade='all, delete-orphan') + bookings: Mapped[List["Booking"]] = relationship( + back_populates='experience') def serialize(self): return { @@ -136,14 +166,17 @@ def serialize(self): 'schedules': [schedule.serialize() for schedule in self.schedules] } + class ExperienceSchedule(db.Model): __tablename__ = 'experience_schedules' - + id: Mapped[int] = mapped_column(primary_key=True) - experience_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=False) - day_of_week: Mapped[DayOfWeek] = mapped_column(SQLEnum(DayOfWeek), nullable=False) + experience_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('experiences.id'), nullable=False) + day_of_week: Mapped[DayOfWeek] = mapped_column( + SQLEnum(DayOfWeek), nullable=False) start_time: Mapped[time] = mapped_column(Time, nullable=False) - + experience: Mapped["Experience"] = relationship(back_populates='schedules') def serialize(self): @@ -154,22 +187,29 @@ def serialize(self): } # ============= HABITACIONES (sin cambios) ============= + + class Room(db.Model): __tablename__ = 'rooms' - + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) capacity: Mapped[int] = mapped_column(Integer, nullable=False) price_per_night: Mapped[float] = mapped_column(Float, nullable=False) - image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + image_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True) amenities: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean(), default=True) - check_in_time: Mapped[time] = mapped_column(Time, default=time(15, 0), nullable=False) - check_out_time: Mapped[time] = mapped_column(Time, default=time(11, 0), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - - booking_rooms: Mapped[List["BookingRoom"]] = relationship(back_populates='room') + check_in_time: Mapped[time] = mapped_column( + Time, default=time(15, 0), nullable=False) + check_out_time: Mapped[time] = mapped_column( + Time, default=time(11, 0), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + + booking_rooms: Mapped[List["BookingRoom"] + ] = relationship(back_populates='room') def serialize(self): return { @@ -186,19 +226,24 @@ def serialize(self): } # ============= EXTRAS (sin cambios) ============= + + class Extra(db.Model): __tablename__ = 'extras' - + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) price: Mapped[float] = mapped_column(Float, nullable=False) type: Mapped[ExtraType] = mapped_column(SQLEnum(ExtraType), nullable=False) - image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + image_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean(), default=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - - booking_extras: Mapped[List["BookingExtra"]] = relationship(back_populates='extra') + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + + booking_extras: Mapped[List["BookingExtra"] + ] = relationship(back_populates='extra') def serialize(self): return { @@ -212,22 +257,29 @@ def serialize(self): } # ============= PAQUETES (sin cambios) ============= + + class Package(db.Model): __tablename__ = 'packages' - + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(200), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) price: Mapped[float] = mapped_column(Float, nullable=False) - image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + image_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean(), default=True) - room_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('rooms.id'), nullable=True) - experience_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - + room_id: Mapped[Optional[int]] = mapped_column( + Integer, db.ForeignKey('rooms.id'), nullable=True) + experience_id: Mapped[Optional[int]] = mapped_column( + Integer, db.ForeignKey('experiences.id'), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + room: Mapped[Optional["Room"]] = relationship() experience: Mapped[Optional["Experience"]] = relationship() - included_extras: Mapped[List["PackageExtra"]] = relationship(back_populates='package', cascade='all, delete-orphan') + included_extras: Mapped[List["PackageExtra"]] = relationship( + back_populates='package', cascade='all, delete-orphan') bookings: Mapped[List["Booking"]] = relationship(back_populates='package') def serialize(self): @@ -243,14 +295,17 @@ def serialize(self): 'is_active': self.is_active } + class PackageExtra(db.Model): __tablename__ = 'package_extras' - + id: Mapped[int] = mapped_column(primary_key=True) - package_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('packages.id'), nullable=False) - extra_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('extras.id'), nullable=False) + package_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('packages.id'), nullable=False) + extra_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('extras.id'), nullable=False) quantity: Mapped[int] = mapped_column(Integer, default=1) - + package: Mapped["Package"] = relationship(back_populates='included_extras') extra: Mapped["Extra"] = relationship() @@ -262,48 +317,69 @@ def serialize(self): } # ============= RESERVAS ============= + + class Booking(db.Model): __tablename__ = 'bookings' - + id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('users.id'), nullable=False) - + user_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('users.id'), nullable=False) + # NUEVO: Número de confirmación único - confirmation_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) - - experience_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=True) - package_id: Mapped[Optional[int]] = mapped_column(Integer, db.ForeignKey('packages.id'), nullable=True) - - experience_date: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True) - experience_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True) - + confirmation_number: Mapped[str] = mapped_column( + String(20), unique=True, nullable=False, index=True) + + experience_id: Mapped[Optional[int]] = mapped_column( + Integer, db.ForeignKey('experiences.id'), nullable=True) + package_id: Mapped[Optional[int]] = mapped_column( + Integer, db.ForeignKey('packages.id'), nullable=True) + + experience_date: Mapped[Optional[datetime] + ] = mapped_column(Date, nullable=True) + experience_time: Mapped[Optional[time] + ] = mapped_column(Time, nullable=True) + check_in: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True) check_out: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True) check_in_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True) check_out_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True) - + number_of_guests: Mapped[int] = mapped_column(Integer, nullable=False) - - status: Mapped[BookingStatus] = mapped_column(SQLEnum(BookingStatus), default=BookingStatus.CART) + + status: Mapped[BookingStatus] = mapped_column( + SQLEnum(BookingStatus), default=BookingStatus.CART) total_price: Mapped[float] = mapped_column(Float, nullable=False) - - stripe_payment_intent_id: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) - stripe_payment_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) - payment_status: Mapped[PaymentStatus] = mapped_column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING) - - special_requests: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + stripe_payment_intent_id: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True) + stripe_payment_status: Mapped[Optional[str] + ] = mapped_column(String(50), nullable=True) + payment_status: Mapped[PaymentStatus] = mapped_column( + SQLEnum(PaymentStatus), default=PaymentStatus.PENDING) + + special_requests: Mapped[Optional[str] + ] = mapped_column(Text, nullable=True) admin_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - cart_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + cart_expires_at: Mapped[Optional[datetime] + ] = mapped_column(DateTime, nullable=True) + # Relaciones user: Mapped["User"] = relationship(back_populates='bookings') - experience: Mapped[Optional["Experience"]] = relationship(back_populates='bookings') - package: Mapped[Optional["Package"]] = relationship(back_populates='bookings') - rooms: Mapped[List["BookingRoom"]] = relationship(back_populates='booking', cascade='all, delete-orphan') - extras: Mapped[List["BookingExtra"]] = relationship(back_populates='booking', cascade='all, delete-orphan') - email_logs: Mapped[List["EmailLog"]] = relationship(back_populates='booking', cascade='all, delete-orphan') + experience: Mapped[Optional["Experience"] + ] = relationship(back_populates='bookings') + package: Mapped[Optional["Package"]] = relationship( + back_populates='bookings') + rooms: Mapped[List["BookingRoom"]] = relationship( + back_populates='booking', cascade='all, delete-orphan') + extras: Mapped[List["BookingExtra"]] = relationship( + back_populates='booking', cascade='all, delete-orphan') + email_logs: Mapped[List["EmailLog"]] = relationship( + back_populates='booking', cascade='all, delete-orphan') def serialize(self): return { @@ -331,7 +407,7 @@ def serialize(self): 'updated_at': self.updated_at.isoformat(), 'cart_expires_at': self.cart_expires_at.isoformat() if self.cart_expires_at else None } - + def serialize_admin(self): data = self.serialize() data['stripe_details'] = { @@ -340,27 +416,31 @@ def serialize_admin(self): 'payment_status_enum': self.payment_status.value } return data - + @staticmethod def generate_confirmation_number(): """Generar número de confirmación único (ej: BK20240115ABCD)""" import random import string date_str = datetime.utcnow().strftime('%Y%m%d') - random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4)) + random_str = ''.join(random.choices( + string.ascii_uppercase + string.digits, k=4)) return f"BK{date_str}{random_str}" + class BookingRoom(db.Model): __tablename__ = 'booking_rooms' - + id: Mapped[int] = mapped_column(primary_key=True) - booking_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('bookings.id'), nullable=False) - room_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('rooms.id'), nullable=False) + booking_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('bookings.id'), nullable=False) + room_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('rooms.id'), nullable=False) check_in: Mapped[datetime] = mapped_column(Date, nullable=False) check_out: Mapped[datetime] = mapped_column(Date, nullable=False) nights: Mapped[int] = mapped_column(Integer, nullable=False) price: Mapped[float] = mapped_column(Float, nullable=False) - + booking: Mapped["Booking"] = relationship(back_populates='rooms') room: Mapped["Room"] = relationship(back_populates='booking_rooms') @@ -374,15 +454,18 @@ def serialize(self): 'price': self.price } + class BookingExtra(db.Model): __tablename__ = 'booking_extras' - + id: Mapped[int] = mapped_column(primary_key=True) - booking_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('bookings.id'), nullable=False) - extra_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('extras.id'), nullable=False) + booking_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('bookings.id'), nullable=False) + extra_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('extras.id'), nullable=False) quantity: Mapped[int] = mapped_column(Integer, default=1) price: Mapped[float] = mapped_column(Float, nullable=False) - + booking: Mapped["Booking"] = relationship(back_populates='extras') extra: Mapped["Extra"] = relationship(back_populates='booking_extras') @@ -395,19 +478,26 @@ def serialize(self): } # ============= EMAIL LOG ============= + + class EmailLog(db.Model): __tablename__ = 'email_logs' - + id: Mapped[int] = mapped_column(primary_key=True) - booking_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('bookings.id'), nullable=False) - email_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'booking_confirmation', 'payment_receipt', etc. + booking_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('bookings.id'), nullable=False) + # 'booking_confirmation', 'payment_receipt', etc. + email_type: Mapped[str] = mapped_column(String(50), nullable=False) recipient_email: Mapped[str] = mapped_column(String(120), nullable=False) subject: Mapped[str] = mapped_column(String(200), nullable=False) - status: Mapped[EmailStatus] = mapped_column(SQLEnum(EmailStatus), default=EmailStatus.PENDING) + status: Mapped[EmailStatus] = mapped_column( + SQLEnum(EmailStatus), default=EmailStatus.PENDING) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - + sent_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + booking: Mapped["Booking"] = relationship(back_populates='email_logs') def serialize(self): @@ -424,17 +514,20 @@ def serialize(self): } # ============= DISPONIBILIDAD (sin cambios) ============= + + class RoomAvailability(db.Model): __tablename__ = 'room_availability' - + id: Mapped[int] = mapped_column(primary_key=True) - room_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('rooms.id'), nullable=False) + room_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('rooms.id'), nullable=False) date: Mapped[datetime] = mapped_column(Date, nullable=False, index=True) is_available: Mapped[bool] = mapped_column(Boolean(), default=True) reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) - + room: Mapped["Room"] = relationship() - + __table_args__ = ( db.UniqueConstraint('room_id', 'date', name='_room_date_uc'), ) @@ -448,16 +541,18 @@ def serialize(self): 'reason': self.reason } + class ExperienceAvailability(db.Model): __tablename__ = 'experience_availability' - + id: Mapped[int] = mapped_column(primary_key=True) - experience_id: Mapped[int] = mapped_column(Integer, db.ForeignKey('experiences.id'), nullable=False) + experience_id: Mapped[int] = mapped_column( + Integer, db.ForeignKey('experiences.id'), nullable=False) date: Mapped[datetime] = mapped_column(Date, nullable=False, index=True) available_spots: Mapped[int] = mapped_column(Integer, nullable=False) - + experience: Mapped["Experience"] = relationship() - + __table_args__ = ( db.UniqueConstraint('experience_id', 'date', name='_experience_date_uc'), ) @@ -471,45 +566,37 @@ def serialize(self): } +class BookingItem(db.Model): __tablename__ = 'booking_items' - + id = db.Column(db.Integer, primary_key=True) booking_id = db.Column(db.Integer, db.ForeignKey('bookings.id'), nullable=False) - - # Item Type (experience or room) - item_type = db.Column(db.String(50), nullable=False) # 'experience' or 'room' - - # Reference to the actual item + + item_type = db.Column(db.String(50), nullable=False) # 'experience' o 'room' + experience_id = db.Column(db.Integer, db.ForeignKey('experiences.id'), nullable=True) room_id = db.Column(db.Integer, db.ForeignKey('rooms.id'), nullable=True) - - # Item Details (cached for historical record) + name = db.Column(db.String(200), nullable=False) image_url = db.Column(db.String(500)) - - # Experience-specific fields - date = db.Column(db.Date, nullable=True) # For experiences + + date = db.Column(db.Date, nullable=True) guests = db.Column(db.Integer) - - # Room-specific fields - check_in = db.Column(db.Date, nullable=True) # For rooms - check_out = db.Column(db.Date, nullable=True) # For rooms + + check_in = db.Column(db.Date, nullable=True) + check_out = db.Column(db.Date, nullable=True) nights = db.Column(db.Integer) - - # Pricing + unit_price = db.Column(db.Float, nullable=False) subtotal = db.Column(db.Float, nullable=False) - - # Extras (stored as JSON) + extras = db.Column(db.JSON) # [{id, name, price}, ...] - - # Timestamps + created_at = db.Column(db.DateTime, default=datetime.utcnow) - - # Relationships + experience = db.relationship('Experience', backref='booking_items') room = db.relationship('Room', backref='booking_items') - + def serialize(self): return { 'id': self.id, @@ -526,4 +613,4 @@ def serialize(self): 'extras': self.extras, 'experience_id': self.experience_id, 'room_id': self.room_id - } \ No newline at end of file + }