mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 16:48:01 -05:00
Add import data feature
This commit is contained in:
283
.superdesign/design_iterations/trip_page_1.html
Normal file
283
.superdesign/design_iterations/trip_page_1.html
Normal file
@@ -0,0 +1,283 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>European Grand Tour - Trip Details</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-black min-h-screen">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Trip Header -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-light tracking-tight text-black mb-4">
|
||||
European Grand Tour
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 max-w-2xl">
|
||||
A 21-day journey through the heart of Europe, discovering historic cities, stunning landscapes, and rich cultural heritage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Map Area - Hero Element -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="h-96 lg:h-[500px] bg-gray-100 relative flex items-center justify-center">
|
||||
<!-- Map Placeholder -->
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-gray-300 rounded-full mx-auto mb-4"></div>
|
||||
<p class="text-gray-500 text-sm">Interactive Map</p>
|
||||
<p class="text-gray-400 text-xs mt-1">Route visualization would appear here</p>
|
||||
</div>
|
||||
|
||||
<!-- Route indicators -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<div class="bg-black text-white px-3 py-1 rounded-full text-xs font-medium">
|
||||
Start: Amsterdam
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-4 right-4">
|
||||
<div class="bg-gray-800 text-white px-3 py-1 rounded-full text-xs font-medium">
|
||||
End: Rome
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="space-y-8">
|
||||
<!-- Trip Statistics -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-medium text-black mb-6">Trip Statistics</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-b border-gray-100 pb-4">
|
||||
<div class="text-2xl font-light text-black">3,247 km</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Total Distance</div>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-100 pb-4">
|
||||
<div class="text-2xl font-light text-black">21 days</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Duration</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xl font-light text-black">7 countries</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Countries Visited</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries List -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-medium text-black mb-4">Countries Visited</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">Netherlands</span>
|
||||
<span class="text-xs text-gray-500">3 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">Germany</span>
|
||||
<span class="text-xs text-gray-500">4 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">Austria</span>
|
||||
<span class="text-xs text-gray-500">2 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">Switzerland</span>
|
||||
<span class="text-xs text-gray-500">3 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">France</span>
|
||||
<span class="text-xs text-gray-500">4 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">Monaco</span>
|
||||
<span class="text-xs text-gray-500">1 day</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-900">Italy</span>
|
||||
<span class="text-xs text-gray-500">4 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section -->
|
||||
<section class="mt-16">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-2xl sm:text-3xl font-light text-black">Trip Photos</h2>
|
||||
<div class="text-sm text-gray-600">147 photos</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<!-- Photo placeholders -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Amsterdam Canal</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Berlin Wall</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Alpine Vista</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Swiss Mountains</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Eiffel Tower</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Monaco Harbor</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Colosseum</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="aspect-square bg-gray-100 relative">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs text-gray-600">Roman Forum</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more indicator -->
|
||||
<div class="col-span-2 sm:col-span-3 lg:col-span-4 xl:col-span-6 mt-8">
|
||||
<button class="w-full py-3 px-4 bg-white border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
Load More Photos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trip Timeline (Additional Context) -->
|
||||
<section class="mt-16">
|
||||
<h2 class="text-2xl sm:text-3xl font-light text-black mb-8">Trip Timeline</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-black rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 1-3: Amsterdam, Netherlands</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Explored canals, visited museums, experienced local culture</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 4-7: Berlin & Munich, Germany</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Historical sites, traditional cuisine, alpine preparation</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 8-9: Salzburg, Austria</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Mozart's birthplace, stunning architecture</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 10-12: Zurich & Alps, Switzerland</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Mountain adventures, pristine lakes, scenic drives</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 13-16: Paris & Lyon, France</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Art, cuisine, romance, and French countryside</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 17: Monaco</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Luxury, casinos, and Mediterranean coastline</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-black">Day 18-21: Rome, Italy</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Ancient history, incredible food, perfect ending</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
238
.superdesign/design_iterations/trip_page_2.html
Normal file
238
.superdesign/design_iterations/trip_page_2.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Asian Adventure - Trip Details</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-white text-black min-h-screen">
|
||||
<!-- Main Container -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Trip Header -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-light tracking-tight mb-2">
|
||||
Asian Adventure
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 font-light">
|
||||
A journey through Southeast Asia's cultural treasures
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
|
||||
<!-- Map Section (Takes up 3/4 on desktop) -->
|
||||
<div class="lg:col-span-3 order-2 lg:order-1">
|
||||
<div class="bg-gray-100 border border-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-gray-300 rounded-full mx-auto mb-4"></div>
|
||||
<p class="text-gray-500 font-light">Interactive Map</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Route visualization</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (Stats and Info) -->
|
||||
<div class="lg:col-span-1 order-1 lg:order-2 space-y-8">
|
||||
|
||||
<!-- Trip Stats -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-medium mb-6 tracking-tight">Trip Statistics</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<div class="text-2xl font-light text-black mb-1">2,847 km</div>
|
||||
<div class="text-sm text-gray-600">Total Distance</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xl font-light text-black mb-1">18 days</div>
|
||||
<div class="text-sm text-gray-600">Duration</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-2xl font-light text-black mb-1">5 countries</div>
|
||||
<div class="text-sm text-gray-600">Countries Visited</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries List -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium mb-4 tracking-tight">Countries</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="font-light">Thailand</span>
|
||||
<span class="text-sm text-gray-500">6 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="font-light">Vietnam</span>
|
||||
<span class="text-sm text-gray-500">4 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="font-light">Cambodia</span>
|
||||
<span class="text-sm text-gray-500">3 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="font-light">Laos</span>
|
||||
<span class="text-sm text-gray-500">3 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="font-light">Myanmar</span>
|
||||
<span class="text-sm text-gray-500">2 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium mb-4 tracking-tight">Highlights</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||
<span class="text-sm font-light">12 temples visited</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||
<span class="text-sm font-light">4 cooking classes</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||
<span class="text-sm font-light">8 markets explored</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-2 bg-black rounded-full"></div>
|
||||
<span class="text-sm font-light">3 boat rides</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section -->
|
||||
<section class="mt-16">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-2xl sm:text-3xl font-light tracking-tight">Trip Photos</h2>
|
||||
<span class="text-sm text-gray-500 font-light">247 photos</span>
|
||||
</div>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<!-- Photo placeholders with varying aspect ratios -->
|
||||
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||
<div class="w-8 h-6 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-[3/4] flex items-center justify-center">
|
||||
<div class="w-6 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||
<div class="w-8 h-6 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-[3/4] flex items-center justify-center">
|
||||
<div class="w-6 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-[4/3] flex items-center justify-center">
|
||||
<div class="w-8 h-6 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-square flex items-center justify-center">
|
||||
<div class="w-8 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg aspect-[3/4] flex items-center justify-center">
|
||||
<div class="w-6 h-8 bg-gray-400 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Button -->
|
||||
<div class="flex justify-center mt-8">
|
||||
<button class="px-8 py-3 border border-gray-300 rounded-lg text-sm font-light hover:bg-gray-50 transition-colors">
|
||||
Load More Photos
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trip Timeline (Additional Section) -->
|
||||
<section class="mt-16 border-t border-gray-200 pt-16">
|
||||
<h2 class="text-2xl sm:text-3xl font-light tracking-tight mb-8">Trip Timeline</h2>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 sm:w-24">
|
||||
<span class="text-sm text-gray-500 font-light">Day 1-6</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-medium mb-2">Bangkok & Northern Thailand</h3>
|
||||
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||
Explored the bustling streets of Bangkok, visited ancient temples, and trekked through the mountains of Chiang Mai.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 sm:w-24">
|
||||
<span class="text-sm text-gray-500 font-light">Day 7-10</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-medium mb-2">Ho Chi Minh City & Hanoi</h3>
|
||||
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||
Discovered Vietnamese culture, cuisine, and history across the country's two major cities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 sm:w-24">
|
||||
<span class="text-sm text-gray-500 font-light">Day 11-13</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-medium mb-2">Siem Reap, Cambodia</h3>
|
||||
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||
Marveled at the ancient temples of Angkor Wat and experienced traditional Khmer culture.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 sm:w-24">
|
||||
<span class="text-sm text-gray-500 font-light">Day 14-16</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-medium mb-2">Luang Prabang, Laos</h3>
|
||||
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||
Experienced the peaceful atmosphere of this UNESCO World Heritage city along the Mekong River.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 sm:w-24">
|
||||
<span class="text-sm text-gray-500 font-light">Day 17-18</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-medium mb-2">Yangon, Myanmar</h3>
|
||||
<p class="text-gray-600 font-light text-sm leading-relaxed">
|
||||
Concluded the journey with visits to golden pagodas and local markets in Myanmar's largest city.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
316
.superdesign/design_iterations/trip_page_3.html
Normal file
316
.superdesign/design_iterations/trip_page_3.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coast to Coast Adventure - Trip Details</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.map-placeholder {
|
||||
background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #f3f4f6 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #f3f4f6 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #f3f4f6 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
||||
.photo-placeholder {
|
||||
background: linear-gradient(135deg, #e5e7eb 0%, #f9fafb 50%, #e5e7eb 100%);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white text-black font-sans antialiased">
|
||||
<!-- Main Container -->
|
||||
<div class="min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
|
||||
<!-- Trip Header -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-light tracking-tight mb-2">
|
||||
Coast to Coast Adventure
|
||||
</h1>
|
||||
<p class="text-lg md:text-xl text-gray-600 font-light">
|
||||
New York City to San Francisco • October 2024
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Main Grid Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
|
||||
<!-- Map Section -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-2xl font-light mb-2">Route Overview</h2>
|
||||
<p class="text-gray-600">Interactive journey across America</p>
|
||||
</div>
|
||||
<div class="map-placeholder h-96 md:h-[500px] flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-gray-300 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-500 font-light">Interactive Map</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div>
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-light mb-6">Trip Statistics</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Distance -->
|
||||
<div class="stat-card bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">Total Distance</p>
|
||||
<p class="text-3xl font-light">2,908 mi</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="stat-card bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">Duration</p>
|
||||
<p class="text-3xl font-light">14 days</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries -->
|
||||
<div class="stat-card bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">States Visited</p>
|
||||
<p class="text-3xl font-light">12</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State List -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-100">
|
||||
<h3 class="text-lg font-light mb-4">States Crossed</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>New York</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Pennsylvania</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Ohio</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Indiana</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Illinois</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Iowa</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Nebraska</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Colorado</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Utah</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>Nevada</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span>California</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section -->
|
||||
<div>
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-light mb-6">Trip Highlights</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<!-- Featured Photo -->
|
||||
<div class="col-span-2">
|
||||
<div class="photo-placeholder h-48 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-2 bg-gray-400 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Golden Gate Bridge</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small Photos -->
|
||||
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Chicago Skyline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Rocky Mountains</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Monument Valley</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="photo-placeholder h-24 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-1 bg-gray-400 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Route 66</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Counter -->
|
||||
<div class="text-center">
|
||||
<button class="text-sm text-gray-600 hover:text-black transition-colors duration-200 border border-gray-200 px-4 py-2 rounded-lg hover:border-gray-300">
|
||||
View all 247 photos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Trip Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Key Stops -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 class="text-xl font-light mb-4">Key Stops</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="text-sm">Times Square, NYC</span>
|
||||
<span class="text-sm text-gray-500">Day 1</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="text-sm">Millennium Park, Chicago</span>
|
||||
<span class="text-sm text-gray-500">Day 4</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="text-sm">Rocky Mountain National Park</span>
|
||||
<span class="text-sm text-gray-500">Day 8</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="text-sm">Arches National Park</span>
|
||||
<span class="text-sm text-gray-500">Day 10</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="text-sm">Golden Gate Bridge, SF</span>
|
||||
<span class="text-sm text-gray-500">Day 14</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weather Summary -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 class="text-xl font-light mb-4">Weather Summary</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Average Temperature</span>
|
||||
<span class="text-sm font-medium">68°F</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Sunny Days</span>
|
||||
<span class="text-sm font-medium">11 of 14</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Rain Days</span>
|
||||
<span class="text-sm font-medium">2 of 14</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Best Weather</span>
|
||||
<span class="text-sm font-medium">Utah, Nevada</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Notes -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 class="text-xl font-light mb-4">Trip Notes</h3>
|
||||
<div class="space-y-3 text-sm text-gray-700">
|
||||
<p>Perfect timing for fall foliage in the Midwest. Colorado mountains were breathtaking with early snow caps.</p>
|
||||
<p>Route 66 sections in Illinois and Missouri provided authentic American road trip experience.</p>
|
||||
<p>Utah's landscape diversity exceeded expectations - from desert to mountain passes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
189
.superdesign/design_iterations/trip_page_3_1.html
Normal file
189
.superdesign/design_iterations/trip_page_3_1.html
Normal file
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coast to Coast Adventure - Trip Details</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.map-placeholder {
|
||||
background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #f3f4f6 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #f3f4f6 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #f3f4f6 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
||||
.photo-placeholder {
|
||||
background: linear-gradient(135deg, #e5e7eb 0%, #f9fafb 50%, #e5e7eb 100%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-black font-sans antialiased">
|
||||
<!-- Main Container -->
|
||||
<div class="min-h-screen p-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
<!-- Compact Header -->
|
||||
<header class="mb-6 bg-white rounded-lg p-4 border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-light tracking-tight">Coast to Coast Adventure</h1>
|
||||
<p class="text-sm text-gray-600">NYC → SF • Oct 2024</p>
|
||||
</div>
|
||||
<div class="text-right text-sm">
|
||||
<div class="text-lg font-light">2,908 mi</div>
|
||||
<div class="text-gray-600">14 days</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
|
||||
<!-- Map Section -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="map-placeholder h-64 lg:h-80 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 mx-auto mb-2 bg-gray-300 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Route Map</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Data Panel -->
|
||||
<div class="space-y-4">
|
||||
<!-- Stats -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium mb-3 text-gray-800">Trip Stats</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Distance</span>
|
||||
<span class="font-medium">2,908 mi</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Duration</span>
|
||||
<span class="font-medium">14 days</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">States</span>
|
||||
<span class="font-medium">12</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Photos</span>
|
||||
<span class="font-medium">247</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact States List -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium mb-3 text-gray-800">Route</h3>
|
||||
<div class="text-xs text-gray-600 leading-relaxed">
|
||||
NY → PA → OH → IN → IL → IA → NE → CO → UT → NV → CA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Photos -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium mb-3 text-gray-800">Highlights</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="photo-placeholder h-16 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Additional Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
<!-- Key Stops -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium mb-3 text-gray-800">Key Stops</h3>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Times Square</span>
|
||||
<span class="text-gray-500">Day 1</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Chicago</span>
|
||||
<span class="text-gray-500">Day 4</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Rocky Mountains</span>
|
||||
<span class="text-gray-500">Day 8</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Arches NP</span>
|
||||
<span class="text-gray-500">Day 10</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Golden Gate</span>
|
||||
<span class="text-gray-500">Day 14</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weather -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium mb-3 text-gray-800">Weather</h3>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Avg Temp</span>
|
||||
<span class="font-medium">68°F</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Sunny Days</span>
|
||||
<span class="font-medium">11/14</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Rain Days</span>
|
||||
<span class="font-medium">2/14</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-1">
|
||||
<span>Best</span>
|
||||
<span class="font-medium">Utah, Nevada</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Notes -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium mb-3 text-gray-800">Notes</h3>
|
||||
<div class="text-xs text-gray-700 space-y-2">
|
||||
<p>Fall foliage in Midwest was perfect timing.</p>
|
||||
<p>Route 66 sections provided authentic experience.</p>
|
||||
<p>Utah landscape diversity exceeded expectations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- [x] All your stats
|
||||
|
||||
- [ ] In the User Settings, you can now import your user data from a zip file. It will import all the data from the zip file, listed above. It will also start stats recalculation.
|
||||
- [ ] User can select to override settings or not.
|
||||
|
||||
- Export file size is now displayed in the exports and imports lists.
|
||||
|
||||
@@ -27,6 +28,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
- Oj is now being used for JSON serialization.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Email links now use the SMTP domain if set. #1469
|
||||
|
||||
# 0.28.1 - 2025-06-11
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
class Settings::UsersController < ApplicationController
|
||||
before_action :authenticate_self_hosted!
|
||||
before_action :authenticate_admin!
|
||||
before_action :authenticate_admin!, except: [:export, :import]
|
||||
before_action :authenticate_user!, only: [:export, :import]
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
@@ -53,7 +54,40 @@ class Settings::UsersController < ApplicationController
|
||||
end
|
||||
|
||||
def import
|
||||
unless params[:archive].present?
|
||||
redirect_to edit_user_registration_path, alert: 'Please select a ZIP archive to import.'
|
||||
return
|
||||
end
|
||||
|
||||
archive_file = params[:archive]
|
||||
|
||||
# Validate file type
|
||||
unless archive_file.content_type == 'application/zip' ||
|
||||
archive_file.content_type == 'application/x-zip-compressed' ||
|
||||
File.extname(archive_file.original_filename).downcase == '.zip'
|
||||
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.'
|
||||
return
|
||||
end
|
||||
|
||||
# Create Import record for user data archive
|
||||
import = current_user.imports.build(
|
||||
name: archive_file.original_filename,
|
||||
source: :user_data_archive
|
||||
)
|
||||
|
||||
import.file.attach(archive_file)
|
||||
|
||||
if import.save
|
||||
redirect_to edit_user_registration_path,
|
||||
notice: 'Your data import has been started. You will receive a notification when it completes.'
|
||||
else
|
||||
redirect_to edit_user_registration_path,
|
||||
alert: 'Failed to start import. Please try again.'
|
||||
end
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'User data import failed to start')
|
||||
redirect_to edit_user_registration_path,
|
||||
alert: 'An error occurred while starting the import. Please try again.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
class Users::ExportDataJob < ApplicationJob
|
||||
queue_as :exports
|
||||
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find(user_id)
|
||||
|
||||
|
||||
64
app/jobs/users/import_data_job.rb
Normal file
64
app/jobs/users/import_data_job.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportDataJob < ApplicationJob
|
||||
queue_as :imports
|
||||
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(import_id)
|
||||
import = Import.find(import_id)
|
||||
user = import.user
|
||||
|
||||
# Download the archive file to a temporary location
|
||||
archive_path = download_import_archive(import)
|
||||
|
||||
# Validate that the archive file exists
|
||||
unless File.exist?(archive_path)
|
||||
raise StandardError, "Archive file not found: #{archive_path}"
|
||||
end
|
||||
|
||||
# Perform the import
|
||||
import_stats = Users::ImportData.new(user, archive_path).import
|
||||
|
||||
Rails.logger.info "Import completed successfully for user #{user.email}: #{import_stats}"
|
||||
rescue StandardError => e
|
||||
user_id = user&.id || import&.user_id || "unknown"
|
||||
ExceptionReporter.call(e, "Import job failed for user #{user_id}")
|
||||
|
||||
# Create failure notification if user is available
|
||||
if user
|
||||
::Notifications::Create.new(
|
||||
user: user,
|
||||
title: 'Data import failed',
|
||||
content: "Your data import failed with error: #{e.message}. Please check the archive format and try again.",
|
||||
kind: :error
|
||||
).call
|
||||
end
|
||||
|
||||
raise e
|
||||
ensure
|
||||
# Clean up the uploaded archive file if it exists
|
||||
if archive_path && File.exist?(archive_path)
|
||||
File.delete(archive_path)
|
||||
Rails.logger.info "Cleaned up archive file: #{archive_path}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def download_import_archive(import)
|
||||
require 'tmpdir'
|
||||
|
||||
timestamp = Time.current.to_i
|
||||
filename = "user_import_#{import.user_id}_#{import.id}_#{timestamp}.zip"
|
||||
temp_path = File.join(Dir.tmpdir, filename)
|
||||
|
||||
File.open(temp_path, 'wb') do |file_handle|
|
||||
import.file.download do |chunk|
|
||||
file_handle.write(chunk)
|
||||
end
|
||||
end
|
||||
|
||||
temp_path
|
||||
end
|
||||
end
|
||||
@@ -11,13 +11,24 @@ class Import < ApplicationRecord
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
|
||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
enum :source, {
|
||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7
|
||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7,
|
||||
user_data_archive: 8
|
||||
}
|
||||
|
||||
def process!
|
||||
Imports::Create.new(user, self).call
|
||||
if user_data_archive?
|
||||
process_user_data_archive!
|
||||
else
|
||||
Imports::Create.new(user, self).call
|
||||
end
|
||||
end
|
||||
|
||||
def process_user_data_archive!
|
||||
Users::ImportDataJob.perform_later(id)
|
||||
end
|
||||
|
||||
def reverse_geocoded_points_count
|
||||
@@ -39,7 +50,7 @@ class Import < ApplicationRecord
|
||||
file.attach(io: raw_file, filename: name, content_type: 'application/json')
|
||||
end
|
||||
|
||||
private
|
||||
private
|
||||
|
||||
def remove_attached_file
|
||||
file.purge_later
|
||||
|
||||
@@ -77,7 +77,7 @@ class Point < ApplicationRecord
|
||||
timestamp.to_s,
|
||||
velocity.to_s,
|
||||
id.to_s,
|
||||
country.to_s
|
||||
country_name.to_s
|
||||
]
|
||||
)
|
||||
end
|
||||
@@ -87,4 +87,9 @@ class Point < ApplicationRecord
|
||||
self.country_id = found_in_country&.id
|
||||
save! if changed?
|
||||
end
|
||||
|
||||
def country_name
|
||||
# Safely get country name from association or attribute
|
||||
self.country&.name || read_attribute(:country) || ''
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ExceptionReporter
|
||||
def self.call(exception)
|
||||
def self.call(exception, human_message = nil)
|
||||
return unless DawarichSettings.self_hosted?
|
||||
|
||||
Rails.logger.error "Exception: #{exception.message}"
|
||||
Rails.logger.error "#{human_message}: #{exception.message}"
|
||||
|
||||
Sentry.capture_exception(exception)
|
||||
end
|
||||
|
||||
@@ -9,13 +9,19 @@ class Imports::Create
|
||||
end
|
||||
|
||||
def call
|
||||
import.update!(status: :processing)
|
||||
|
||||
importer(import.source).new(import, user.id).call
|
||||
|
||||
schedule_stats_creating(user.id)
|
||||
schedule_visit_suggesting(user.id, import)
|
||||
update_import_points_count(import)
|
||||
rescue StandardError => e
|
||||
import.update!(status: :failed)
|
||||
|
||||
create_import_failed_notification(import, user, e)
|
||||
ensure
|
||||
import.update!(status: :completed) if import.completed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
18
app/services/notifications.rb
Normal file
18
app/services/notifications.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Notifications
|
||||
class Create
|
||||
attr_reader :user, :kind, :title, :content
|
||||
|
||||
def initialize(user:, kind:, title:, content:)
|
||||
@user = user
|
||||
@kind = kind
|
||||
@title = title
|
||||
@content = content
|
||||
end
|
||||
|
||||
def call
|
||||
Notification.create!(user:, kind:, title:, content:)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Notifications::Create
|
||||
attr_reader :user, :kind, :title, :content
|
||||
|
||||
def initialize(user:, kind:, title:, content:)
|
||||
@user = user
|
||||
@kind = kind
|
||||
@title = title
|
||||
@content = content
|
||||
end
|
||||
|
||||
def call
|
||||
Notification.create!(user:, kind:, title:, content:)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,17 @@ require 'zip'
|
||||
#
|
||||
# Output JSON Structure Example:
|
||||
# {
|
||||
# "counts": {
|
||||
# "areas": 5,
|
||||
# "imports": 12,
|
||||
# "exports": 3,
|
||||
# "trips": 8,
|
||||
# "stats": 24,
|
||||
# "notifications": 10,
|
||||
# "points": 15000,
|
||||
# "visits": 45,
|
||||
# "places": 20
|
||||
# },
|
||||
# "settings": {
|
||||
# "distance_unit": "km",
|
||||
# "timezone": "UTC",
|
||||
@@ -227,7 +238,11 @@ class Users::ExportData
|
||||
|
||||
# Stream JSON writing instead of building in memory
|
||||
File.open(json_file_path, 'w') do |file|
|
||||
file.write('{"settings":')
|
||||
# Start JSON and add counts summary
|
||||
file.write('{"counts":')
|
||||
file.write(calculate_entity_counts.to_json)
|
||||
|
||||
file.write(',"settings":')
|
||||
file.write(user.safe_settings.settings.to_json)
|
||||
|
||||
file.write(',"areas":')
|
||||
@@ -281,7 +296,7 @@ class Users::ExportData
|
||||
# Mark export as failed if an error occurs
|
||||
export_record.update!(status: :failed) if export_record
|
||||
|
||||
ExceptionReporter.call(e)
|
||||
ExceptionReporter.call(e, 'Export failed')
|
||||
|
||||
raise e
|
||||
ensure
|
||||
@@ -302,30 +317,44 @@ class Users::ExportData
|
||||
@files_directory
|
||||
end
|
||||
|
||||
def calculate_entity_counts
|
||||
Rails.logger.info "Calculating entity counts for export"
|
||||
|
||||
counts = {
|
||||
areas: user.areas.count,
|
||||
imports: user.imports.count,
|
||||
exports: user.exports.count,
|
||||
trips: user.trips.count,
|
||||
stats: user.stats.count,
|
||||
notifications: user.notifications.count,
|
||||
points: user.tracked_points.count,
|
||||
visits: user.visits.count,
|
||||
places: user.places.count
|
||||
}
|
||||
|
||||
Rails.logger.info "Entity counts: #{counts}"
|
||||
counts
|
||||
end
|
||||
|
||||
def create_zip_archive(export_directory, zip_file_path)
|
||||
# Set global compression level for better file size reduction
|
||||
original_compression = Zip.default_compression
|
||||
Zip.default_compression = Zlib::BEST_COMPRESSION
|
||||
|
||||
# Create zip archive with optimized compression
|
||||
Zip::File.open(zip_file_path, Zip::File::CREATE) do |zipfile|
|
||||
# Set higher compression for better file size reduction
|
||||
zipfile.default_compression = Zip::Entry::DEFLATED
|
||||
zipfile.default_compression_level = 9 # Maximum compression
|
||||
|
||||
Dir.glob(export_directory.join('**', '*')).each do |file|
|
||||
next if File.directory?(file) || file == zip_file_path.to_s
|
||||
|
||||
relative_path = file.sub(export_directory.to_s + '/', '')
|
||||
|
||||
# Add file with specific compression settings
|
||||
zipfile.add(relative_path, file) do |entry|
|
||||
# JSON files compress very well, so use maximum compression
|
||||
if file.end_with?('.json')
|
||||
entry.compression_level = 9
|
||||
else
|
||||
# For other files (images, etc.), use balanced compression
|
||||
entry.compression_level = 6
|
||||
end
|
||||
end
|
||||
# Add file to the zip archive
|
||||
zipfile.add(relative_path, file)
|
||||
end
|
||||
end
|
||||
ensure
|
||||
# Restore original compression level
|
||||
Zip.default_compression = original_compression if original_compression
|
||||
end
|
||||
|
||||
def cleanup_temporary_files(export_directory)
|
||||
@@ -334,14 +363,17 @@ class Users::ExportData
|
||||
Rails.logger.info "Cleaning up temporary export directory: #{export_directory}"
|
||||
FileUtils.rm_rf(export_directory)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e)
|
||||
ExceptionReporter.call(e, 'Failed to cleanup temporary files')
|
||||
end
|
||||
|
||||
def create_success_notification
|
||||
counts = calculate_entity_counts
|
||||
summary = "#{counts[:points]} points, #{counts[:visits]} visits, #{counts[:places]} places, #{counts[:trips]} trips"
|
||||
|
||||
::Notifications::Create.new(
|
||||
user: user,
|
||||
title: 'Export completed',
|
||||
content: 'Your data export has been processed successfully. You can download it from the exports page.',
|
||||
content: "Your data export has been processed successfully (#{summary}). You can download it from the exports page.",
|
||||
kind: :info
|
||||
).call
|
||||
end
|
||||
|
||||
@@ -9,14 +9,15 @@ class Users::ExportData::Points
|
||||
# Single optimized query with all joins to avoid N+1 queries
|
||||
points_sql = <<-SQL
|
||||
SELECT
|
||||
p.battery_status, p.battery, p.timestamp, p.altitude, p.velocity, p.accuracy,
|
||||
p.id, p.battery_status, p.battery, p.timestamp, p.altitude, p.velocity, p.accuracy,
|
||||
p.ping, p.tracker_id, p.topic, p.trigger, p.bssid, p.ssid, p.connection,
|
||||
p.vertical_accuracy, p.mode, p.inrids, p.in_regions, p.raw_data,
|
||||
p.city, p.country, p.geodata, p.reverse_geocoded_at, p.course,
|
||||
p.course_accuracy, p.external_track_id, p.created_at, p.updated_at,
|
||||
p.lonlat,
|
||||
ST_X(p.lonlat::geometry) as longitude,
|
||||
ST_Y(p.lonlat::geometry) as latitude,
|
||||
p.lonlat, p.longitude, p.latitude,
|
||||
-- Extract coordinates from lonlat if individual fields are missing
|
||||
COALESCE(p.longitude, ST_X(p.lonlat::geometry)) as computed_longitude,
|
||||
COALESCE(p.latitude, ST_Y(p.lonlat::geometry)) as computed_latitude,
|
||||
-- Import reference
|
||||
i.name as import_name,
|
||||
i.source as import_source,
|
||||
@@ -42,7 +43,16 @@ class Users::ExportData::Points
|
||||
Rails.logger.info "Processing #{result.count} points for export..."
|
||||
|
||||
# Process results efficiently
|
||||
result.map do |row|
|
||||
result.filter_map do |row|
|
||||
# Skip points without any coordinate data
|
||||
has_lonlat = row['lonlat'].present?
|
||||
has_coordinates = row['computed_longitude'].present? && row['computed_latitude'].present?
|
||||
|
||||
unless has_lonlat || has_coordinates
|
||||
Rails.logger.debug "Skipping point without coordinates: id=#{row['id'] || 'unknown'}"
|
||||
next
|
||||
end
|
||||
|
||||
point_hash = {
|
||||
'battery_status' => row['battery_status'],
|
||||
'battery' => row['battery'],
|
||||
@@ -70,11 +80,12 @@ class Users::ExportData::Points
|
||||
'course_accuracy' => row['course_accuracy'],
|
||||
'external_track_id' => row['external_track_id'],
|
||||
'created_at' => row['created_at'],
|
||||
'updated_at' => row['updated_at'],
|
||||
'longitude' => row['longitude'],
|
||||
'latitude' => row['latitude']
|
||||
'updated_at' => row['updated_at']
|
||||
}
|
||||
|
||||
# Ensure all coordinate fields are populated
|
||||
populate_coordinate_fields(point_hash, row)
|
||||
|
||||
# Add relationship references only if they exist
|
||||
if row['import_name']
|
||||
point_hash['import_reference'] = {
|
||||
@@ -107,4 +118,22 @@ class Users::ExportData::Points
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
|
||||
def populate_coordinate_fields(point_hash, row)
|
||||
longitude = row['computed_longitude']
|
||||
latitude = row['computed_latitude']
|
||||
lonlat = row['lonlat']
|
||||
|
||||
# If lonlat is present, use it and the computed coordinates
|
||||
if lonlat.present?
|
||||
point_hash['lonlat'] = lonlat
|
||||
point_hash['longitude'] = longitude
|
||||
point_hash['latitude'] = latitude
|
||||
elsif longitude.present? && latitude.present?
|
||||
# If lonlat is missing but we have coordinates, reconstruct lonlat
|
||||
point_hash['longitude'] = longitude
|
||||
point_hash['latitude'] = latitude
|
||||
point_hash['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
202
app/services/users/import_data.rb
Normal file
202
app/services/users/import_data.rb
Normal file
@@ -0,0 +1,202 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'zip'
|
||||
|
||||
# Users::ImportData - Imports complete user data from exported archive
|
||||
#
|
||||
# This service processes a ZIP archive created by Users::ExportData and recreates
|
||||
# the user's data with preserved relationships. The import follows a specific order
|
||||
# to handle foreign key dependencies:
|
||||
#
|
||||
# 1. Settings (applied directly to user)
|
||||
# 2. Areas (standalone user data)
|
||||
# 3. Places (referenced by visits)
|
||||
# 4. Imports (including file attachments)
|
||||
# 5. Exports (including file attachments)
|
||||
# 6. Trips (standalone user data)
|
||||
# 7. Stats (standalone user data)
|
||||
# 8. Notifications (standalone user data)
|
||||
# 9. Visits (references places)
|
||||
# 10. Points (references imports, countries, visits)
|
||||
#
|
||||
# Files are restored to their original locations and properly attached to records.
|
||||
|
||||
class Users::ImportData
|
||||
def initialize(user, archive_path)
|
||||
@user = user
|
||||
@archive_path = archive_path
|
||||
@import_stats = {
|
||||
settings_updated: false,
|
||||
areas_created: 0,
|
||||
places_created: 0,
|
||||
imports_created: 0,
|
||||
exports_created: 0,
|
||||
trips_created: 0,
|
||||
stats_created: 0,
|
||||
notifications_created: 0,
|
||||
visits_created: 0,
|
||||
points_created: 0,
|
||||
files_restored: 0
|
||||
}
|
||||
end
|
||||
|
||||
def import
|
||||
# Create a temporary directory for extraction
|
||||
@import_directory = Rails.root.join('tmp', "import_#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_#{Time.current.to_i}")
|
||||
FileUtils.mkdir_p(@import_directory)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
extract_archive
|
||||
data = load_json_data
|
||||
|
||||
import_in_correct_order(data)
|
||||
|
||||
create_success_notification
|
||||
|
||||
@import_stats
|
||||
end
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Data import failed')
|
||||
create_failure_notification(e)
|
||||
raise e
|
||||
ensure
|
||||
cleanup_temporary_files(@import_directory) if @import_directory&.exist?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :archive_path, :import_stats
|
||||
|
||||
def extract_archive
|
||||
Rails.logger.info "Extracting archive: #{archive_path}"
|
||||
|
||||
Zip::File.open(archive_path) do |zip_file|
|
||||
zip_file.each do |entry|
|
||||
extraction_path = @import_directory.join(entry.name)
|
||||
|
||||
# Ensure directory exists
|
||||
FileUtils.mkdir_p(File.dirname(extraction_path))
|
||||
|
||||
# Extract file
|
||||
entry.extract(extraction_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def load_json_data
|
||||
json_path = @import_directory.join('data.json')
|
||||
|
||||
unless File.exist?(json_path)
|
||||
raise StandardError, "Data file not found in archive: data.json"
|
||||
end
|
||||
|
||||
JSON.parse(File.read(json_path))
|
||||
rescue JSON::ParserError => e
|
||||
raise StandardError, "Invalid JSON format in data file: #{e.message}"
|
||||
end
|
||||
|
||||
def import_in_correct_order(data)
|
||||
Rails.logger.info "Starting data import for user: #{user.email}"
|
||||
|
||||
# Log expected counts if available
|
||||
if data['counts']
|
||||
Rails.logger.info "Expected entity counts from export: #{data['counts']}"
|
||||
end
|
||||
|
||||
# Import in dependency order
|
||||
import_settings(data['settings']) if data['settings']
|
||||
import_areas(data['areas']) if data['areas']
|
||||
import_places(data['places']) if data['places']
|
||||
import_imports(data['imports']) if data['imports']
|
||||
import_exports(data['exports']) if data['exports']
|
||||
import_trips(data['trips']) if data['trips']
|
||||
import_stats(data['stats']) if data['stats']
|
||||
import_notifications(data['notifications']) if data['notifications']
|
||||
import_visits(data['visits']) if data['visits']
|
||||
import_points(data['points']) if data['points']
|
||||
|
||||
Rails.logger.info "Data import completed. Stats: #{@import_stats}"
|
||||
end
|
||||
|
||||
def import_settings(settings_data)
|
||||
Users::ImportData::Settings.new(user, settings_data).call
|
||||
@import_stats[:settings_updated] = true
|
||||
end
|
||||
|
||||
def import_areas(areas_data)
|
||||
areas_created = Users::ImportData::Areas.new(user, areas_data).call
|
||||
@import_stats[:areas_created] = areas_created
|
||||
end
|
||||
|
||||
def import_places(places_data)
|
||||
places_created = Users::ImportData::Places.new(user, places_data).call
|
||||
@import_stats[:places_created] = places_created
|
||||
end
|
||||
|
||||
def import_imports(imports_data)
|
||||
imports_created, files_restored = Users::ImportData::Imports.new(user, imports_data, @import_directory.join('files')).call
|
||||
@import_stats[:imports_created] = imports_created
|
||||
@import_stats[:files_restored] += files_restored
|
||||
end
|
||||
|
||||
def import_exports(exports_data)
|
||||
exports_created, files_restored = Users::ImportData::Exports.new(user, exports_data, @import_directory.join('files')).call
|
||||
@import_stats[:exports_created] = exports_created
|
||||
@import_stats[:files_restored] += files_restored
|
||||
end
|
||||
|
||||
def import_trips(trips_data)
|
||||
trips_created = Users::ImportData::Trips.new(user, trips_data).call
|
||||
@import_stats[:trips_created] = trips_created
|
||||
end
|
||||
|
||||
def import_stats(stats_data)
|
||||
stats_created = Users::ImportData::Stats.new(user, stats_data).call
|
||||
@import_stats[:stats_created] = stats_created
|
||||
end
|
||||
|
||||
def import_notifications(notifications_data)
|
||||
notifications_created = Users::ImportData::Notifications.new(user, notifications_data).call
|
||||
@import_stats[:notifications_created] = notifications_created
|
||||
end
|
||||
|
||||
def import_visits(visits_data)
|
||||
visits_created = Users::ImportData::Visits.new(user, visits_data).call
|
||||
@import_stats[:visits_created] = visits_created
|
||||
end
|
||||
|
||||
def import_points(points_data)
|
||||
points_created = Users::ImportData::Points.new(user, points_data).call
|
||||
@import_stats[:points_created] = points_created
|
||||
end
|
||||
|
||||
def cleanup_temporary_files(import_directory)
|
||||
return unless File.directory?(import_directory)
|
||||
|
||||
Rails.logger.info "Cleaning up temporary import directory: #{import_directory}"
|
||||
FileUtils.rm_rf(import_directory)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Failed to cleanup temporary files')
|
||||
end
|
||||
|
||||
def create_success_notification
|
||||
summary = "#{@import_stats[:points_created]} points, #{@import_stats[:visits_created]} visits, " \
|
||||
"#{@import_stats[:places_created]} places, #{@import_stats[:trips_created]} trips"
|
||||
|
||||
::Notifications::Create.new(
|
||||
user: user,
|
||||
title: 'Data import completed',
|
||||
content: "Your data has been imported successfully (#{summary}).",
|
||||
kind: :info
|
||||
).call
|
||||
end
|
||||
|
||||
def create_failure_notification(error)
|
||||
::Notifications::Create.new(
|
||||
user: user,
|
||||
title: 'Data import failed',
|
||||
content: "Your data import failed with error: #{error.message}. Please check the archive format and try again.",
|
||||
kind: :error
|
||||
).call
|
||||
end
|
||||
end
|
||||
53
app/services/users/import_data/areas.rb
Normal file
53
app/services/users/import_data/areas.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Areas
|
||||
def initialize(user, areas_data)
|
||||
@user = user
|
||||
@areas_data = areas_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless areas_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{areas_data.size} areas for user: #{user.email}"
|
||||
|
||||
areas_created = 0
|
||||
|
||||
areas_data.each do |area_data|
|
||||
next unless area_data.is_a?(Hash)
|
||||
|
||||
# Skip if area already exists (match by name and coordinates)
|
||||
existing_area = user.areas.find_by(
|
||||
name: area_data['name'],
|
||||
latitude: area_data['latitude'],
|
||||
longitude: area_data['longitude']
|
||||
)
|
||||
|
||||
if existing_area
|
||||
Rails.logger.debug "Area already exists: #{area_data['name']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new area
|
||||
area_attributes = area_data.merge(user: user)
|
||||
# Ensure radius is present (required by model validation)
|
||||
area_attributes['radius'] ||= 100 # Default radius if not provided
|
||||
|
||||
area = user.areas.create!(area_attributes)
|
||||
areas_created += 1
|
||||
|
||||
Rails.logger.debug "Created area: #{area.name}"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
ExceptionReporter.call(e, "Failed to create area")
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
Rails.logger.info "Areas import completed. Created: #{areas_created}"
|
||||
areas_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :areas_data
|
||||
end
|
||||
92
app/services/users/import_data/exports.rb
Normal file
92
app/services/users/import_data/exports.rb
Normal file
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Exports
|
||||
def initialize(user, exports_data, files_directory)
|
||||
@user = user
|
||||
@exports_data = exports_data
|
||||
@files_directory = files_directory
|
||||
end
|
||||
|
||||
def call
|
||||
return [0, 0] unless exports_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{exports_data.size} exports for user: #{user.email}"
|
||||
|
||||
exports_created = 0
|
||||
files_restored = 0
|
||||
|
||||
exports_data.each do |export_data|
|
||||
next unless export_data.is_a?(Hash)
|
||||
|
||||
# Check if export already exists (match by name and created_at)
|
||||
existing_export = user.exports.find_by(
|
||||
name: export_data['name'],
|
||||
created_at: export_data['created_at']
|
||||
)
|
||||
|
||||
if existing_export
|
||||
Rails.logger.debug "Export already exists: #{export_data['name']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new export
|
||||
export_record = create_export_record(export_data)
|
||||
exports_created += 1
|
||||
|
||||
# Restore file if present
|
||||
if export_data['file_name'] && restore_export_file(export_record, export_data)
|
||||
files_restored += 1
|
||||
end
|
||||
|
||||
Rails.logger.debug "Created export: #{export_record.name}"
|
||||
end
|
||||
|
||||
Rails.logger.info "Exports import completed. Created: #{exports_created}, Files: #{files_restored}"
|
||||
[exports_created, files_restored]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :exports_data, :files_directory
|
||||
|
||||
def create_export_record(export_data)
|
||||
export_attributes = prepare_export_attributes(export_data)
|
||||
user.exports.create!(export_attributes)
|
||||
end
|
||||
|
||||
def prepare_export_attributes(export_data)
|
||||
export_data.except(
|
||||
'file_name',
|
||||
'original_filename',
|
||||
'file_size',
|
||||
'content_type',
|
||||
'file_error'
|
||||
).merge(user: user)
|
||||
end
|
||||
|
||||
def restore_export_file(export_record, export_data)
|
||||
file_path = files_directory.join(export_data['file_name'])
|
||||
|
||||
unless File.exist?(file_path)
|
||||
Rails.logger.warn "Export file not found: #{export_data['file_name']}"
|
||||
return false
|
||||
end
|
||||
|
||||
begin
|
||||
# Attach the file to the export record
|
||||
export_record.file.attach(
|
||||
io: File.open(file_path),
|
||||
filename: export_data['original_filename'] || export_data['file_name'],
|
||||
content_type: export_data['content_type'] || 'application/octet-stream'
|
||||
)
|
||||
|
||||
Rails.logger.debug "Restored file for export: #{export_record.name}"
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Export file restoration failed")
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
102
app/services/users/import_data/imports.rb
Normal file
102
app/services/users/import_data/imports.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Imports
|
||||
def initialize(user, imports_data, files_directory)
|
||||
@user = user
|
||||
@imports_data = imports_data
|
||||
@files_directory = files_directory
|
||||
end
|
||||
|
||||
def call
|
||||
return [0, 0] unless imports_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{imports_data.size} imports for user: #{user.email}"
|
||||
|
||||
imports_created = 0
|
||||
files_restored = 0
|
||||
|
||||
imports_data.each do |import_data|
|
||||
next unless import_data.is_a?(Hash)
|
||||
|
||||
# Check if import already exists (match by name, source, and created_at)
|
||||
existing_import = user.imports.find_by(
|
||||
name: import_data['name'],
|
||||
source: import_data['source'],
|
||||
created_at: import_data['created_at']
|
||||
)
|
||||
|
||||
if existing_import
|
||||
Rails.logger.debug "Import already exists: #{import_data['name']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new import
|
||||
import_record = create_import_record(import_data)
|
||||
next unless import_record # Skip if creation failed
|
||||
|
||||
imports_created += 1
|
||||
|
||||
# Restore file if present
|
||||
if import_data['file_name'] && restore_import_file(import_record, import_data)
|
||||
files_restored += 1
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "Imports import completed. Created: #{imports_created}, Files restored: #{files_restored}"
|
||||
[imports_created, files_restored]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :imports_data, :files_directory
|
||||
|
||||
def create_import_record(import_data)
|
||||
import_attributes = prepare_import_attributes(import_data)
|
||||
|
||||
begin
|
||||
import_record = user.imports.create!(import_attributes)
|
||||
Rails.logger.debug "Created import: #{import_record.name}"
|
||||
import_record
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create import: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_import_attributes(import_data)
|
||||
import_data.except(
|
||||
'file_name',
|
||||
'original_filename',
|
||||
'file_size',
|
||||
'content_type',
|
||||
'file_error',
|
||||
'updated_at'
|
||||
).merge(user: user)
|
||||
end
|
||||
|
||||
def restore_import_file(import_record, import_data)
|
||||
file_path = files_directory.join(import_data['file_name'])
|
||||
|
||||
unless File.exist?(file_path)
|
||||
Rails.logger.warn "Import file not found: #{import_data['file_name']}"
|
||||
return false
|
||||
end
|
||||
|
||||
begin
|
||||
# Attach the file to the import record
|
||||
import_record.file.attach(
|
||||
io: File.open(file_path),
|
||||
filename: import_data['original_filename'] || import_data['file_name'],
|
||||
content_type: import_data['content_type'] || 'application/octet-stream'
|
||||
)
|
||||
|
||||
Rails.logger.debug "Restored file for import: #{import_record.name}"
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Import file restoration failed")
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
49
app/services/users/import_data/notifications.rb
Normal file
49
app/services/users/import_data/notifications.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Notifications
|
||||
def initialize(user, notifications_data)
|
||||
@user = user
|
||||
@notifications_data = notifications_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless notifications_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{notifications_data.size} notifications for user: #{user.email}"
|
||||
|
||||
notifications_created = 0
|
||||
|
||||
notifications_data.each do |notification_data|
|
||||
next unless notification_data.is_a?(Hash)
|
||||
|
||||
# Check if notification already exists (match by title, content, and created_at)
|
||||
existing_notification = user.notifications.find_by(
|
||||
title: notification_data['title'],
|
||||
content: notification_data['content'],
|
||||
created_at: notification_data['created_at']
|
||||
)
|
||||
|
||||
if existing_notification
|
||||
Rails.logger.debug "Notification already exists: #{notification_data['title']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new notification
|
||||
notification_attributes = notification_data.except('created_at', 'updated_at')
|
||||
notification = user.notifications.create!(notification_attributes)
|
||||
notifications_created += 1
|
||||
|
||||
Rails.logger.debug "Created notification: #{notification.title}"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create notification: #{e.message}"
|
||||
next
|
||||
end
|
||||
|
||||
Rails.logger.info "Notifications import completed. Created: #{notifications_created}"
|
||||
notifications_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :notifications_data
|
||||
end
|
||||
76
app/services/users/import_data/places.rb
Normal file
76
app/services/users/import_data/places.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Places
|
||||
def initialize(user, places_data)
|
||||
@user = user
|
||||
@places_data = places_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless places_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{places_data.size} places for user: #{user.email}"
|
||||
|
||||
places_created = 0
|
||||
|
||||
places_data.each do |place_data|
|
||||
next unless place_data.is_a?(Hash)
|
||||
|
||||
# Find or create place by name and coordinates
|
||||
place = find_or_create_place(place_data)
|
||||
places_created += 1 if place&.respond_to?(:previously_new_record?) && place.previously_new_record?
|
||||
end
|
||||
|
||||
Rails.logger.info "Places import completed. Created: #{places_created}"
|
||||
places_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :places_data
|
||||
|
||||
def find_or_create_place(place_data)
|
||||
name = place_data['name']
|
||||
latitude = place_data['latitude']&.to_f
|
||||
longitude = place_data['longitude']&.to_f
|
||||
|
||||
# Skip if essential data is missing
|
||||
unless name.present? && latitude.present? && longitude.present?
|
||||
Rails.logger.debug "Skipping place with missing required data: #{place_data.inspect}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Try to find existing place by name first, then by coordinates
|
||||
existing_place = Place.find_by(name: name)
|
||||
|
||||
# If no place with same name, check by coordinates
|
||||
unless existing_place
|
||||
existing_place = Place.where(latitude: latitude, longitude: longitude).first
|
||||
end
|
||||
|
||||
if existing_place
|
||||
Rails.logger.debug "Place already exists: #{name}"
|
||||
existing_place.define_singleton_method(:previously_new_record?) { false }
|
||||
return existing_place
|
||||
end
|
||||
|
||||
# Create new place with lonlat point
|
||||
place_attributes = place_data.except('created_at', 'updated_at', 'latitude', 'longitude')
|
||||
place_attributes['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||
place_attributes['latitude'] = latitude
|
||||
place_attributes['longitude'] = longitude
|
||||
# Remove any user reference since Place doesn't belong to user directly
|
||||
place_attributes.delete('user')
|
||||
|
||||
begin
|
||||
place = Place.create!(place_attributes)
|
||||
place.define_singleton_method(:previously_new_record?) { true }
|
||||
Rails.logger.debug "Created place: #{place.name}"
|
||||
|
||||
place
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create place: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
191
app/services/users/import_data/points.rb
Normal file
191
app/services/users/import_data/points.rb
Normal file
@@ -0,0 +1,191 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Points
|
||||
def initialize(user, points_data)
|
||||
@user = user
|
||||
@points_data = points_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless points_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{points_data.size} points for user: #{user.email}"
|
||||
|
||||
points_created = 0
|
||||
skipped_invalid = 0
|
||||
|
||||
points_data.each do |point_data|
|
||||
next unless point_data.is_a?(Hash)
|
||||
|
||||
# Skip points with invalid or missing required data
|
||||
unless valid_point_data?(point_data)
|
||||
skipped_invalid += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Check if point already exists (match by coordinates, timestamp, and user)
|
||||
if point_exists?(point_data)
|
||||
next
|
||||
end
|
||||
|
||||
# Create new point
|
||||
point_record = create_point_record(point_data)
|
||||
points_created += 1 if point_record
|
||||
|
||||
if points_created % 1000 == 0
|
||||
Rails.logger.debug "Imported #{points_created} points..."
|
||||
end
|
||||
end
|
||||
|
||||
if skipped_invalid > 0
|
||||
Rails.logger.warn "Skipped #{skipped_invalid} points with invalid or missing required data"
|
||||
end
|
||||
|
||||
Rails.logger.info "Points import completed. Created: #{points_created}"
|
||||
points_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :points_data
|
||||
|
||||
def point_exists?(point_data)
|
||||
return false unless point_data['lonlat'].present? && point_data['timestamp'].present?
|
||||
|
||||
Point.exists?(
|
||||
lonlat: point_data['lonlat'],
|
||||
timestamp: point_data['timestamp'],
|
||||
user_id: user.id
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug "Error checking if point exists: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def create_point_record(point_data)
|
||||
point_attributes = prepare_point_attributes(point_data)
|
||||
|
||||
begin
|
||||
# Create point and skip the automatic country assignment callback since we're handling it manually
|
||||
point = Point.create!(point_attributes)
|
||||
|
||||
# If we have a country assigned via country_info, update the point to set it
|
||||
if point_attributes[:country].present?
|
||||
point.update_column(:country_id, point_attributes[:country].id)
|
||||
point.reload
|
||||
end
|
||||
|
||||
point
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create point: #{e.message}"
|
||||
Rails.logger.error "Point data: #{point_data.inspect}"
|
||||
Rails.logger.error "Prepared attributes: #{point_attributes.inspect}"
|
||||
nil
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Unexpected error creating point: #{e.message}"
|
||||
Rails.logger.error "Point data: #{point_data.inspect}"
|
||||
Rails.logger.error "Prepared attributes: #{point_attributes.inspect}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_point_attributes(point_data)
|
||||
# Start with base attributes, excluding fields that need special handling
|
||||
attributes = point_data.except(
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'import_reference',
|
||||
'country_info',
|
||||
'visit_reference',
|
||||
'country' # Exclude the string country field - handled via country_info relationship
|
||||
).merge(user: user)
|
||||
|
||||
# Handle lonlat reconstruction if missing (for backward compatibility)
|
||||
ensure_lonlat_field(attributes, point_data)
|
||||
|
||||
# Find and assign related records
|
||||
assign_import_reference(attributes, point_data['import_reference'])
|
||||
assign_country_reference(attributes, point_data['country_info'])
|
||||
assign_visit_reference(attributes, point_data['visit_reference'])
|
||||
|
||||
attributes
|
||||
end
|
||||
|
||||
def assign_import_reference(attributes, import_reference)
|
||||
return unless import_reference.is_a?(Hash)
|
||||
|
||||
import = user.imports.find_by(
|
||||
name: import_reference['name'],
|
||||
source: import_reference['source'],
|
||||
created_at: import_reference['created_at']
|
||||
)
|
||||
|
||||
attributes[:import] = import if import
|
||||
end
|
||||
|
||||
def assign_country_reference(attributes, country_info)
|
||||
return unless country_info.is_a?(Hash)
|
||||
|
||||
# Try to find country by all attributes first
|
||||
country = Country.find_by(
|
||||
name: country_info['name'],
|
||||
iso_a2: country_info['iso_a2'],
|
||||
iso_a3: country_info['iso_a3']
|
||||
)
|
||||
|
||||
# If not found by all attributes, try to find by name only
|
||||
if country.nil? && country_info['name'].present?
|
||||
country = Country.find_by(name: country_info['name'])
|
||||
end
|
||||
|
||||
# If still not found, create a new country record with minimal data
|
||||
if country.nil? && country_info['name'].present?
|
||||
country = Country.find_or_create_by(name: country_info['name']) do |new_country|
|
||||
new_country.iso_a2 = country_info['iso_a2'] || country_info['name'][0..1].upcase
|
||||
new_country.iso_a3 = country_info['iso_a3'] || country_info['name'][0..2].upcase
|
||||
new_country.geom = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))" # Default geometry
|
||||
end
|
||||
end
|
||||
|
||||
attributes[:country] = country if country
|
||||
end
|
||||
|
||||
def assign_visit_reference(attributes, visit_reference)
|
||||
return unless visit_reference.is_a?(Hash)
|
||||
|
||||
visit = user.visits.find_by(
|
||||
name: visit_reference['name'],
|
||||
started_at: visit_reference['started_at'],
|
||||
ended_at: visit_reference['ended_at']
|
||||
)
|
||||
|
||||
attributes[:visit] = visit if visit
|
||||
end
|
||||
|
||||
def valid_point_data?(point_data)
|
||||
# Check for required fields
|
||||
return false unless point_data.is_a?(Hash)
|
||||
return false unless point_data['timestamp'].present?
|
||||
|
||||
# Check if we have either lonlat or longitude/latitude
|
||||
has_lonlat = point_data['lonlat'].present? && point_data['lonlat'].is_a?(String) && point_data['lonlat'].start_with?('POINT(')
|
||||
has_coordinates = point_data['longitude'].present? && point_data['latitude'].present?
|
||||
|
||||
return false unless has_lonlat || has_coordinates
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug "Point validation failed: #{e.message} for data: #{point_data.inspect}"
|
||||
false
|
||||
end
|
||||
|
||||
def ensure_lonlat_field(attributes, point_data)
|
||||
# If lonlat is missing but we have longitude/latitude, reconstruct it
|
||||
if attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present?
|
||||
longitude = point_data['longitude'].to_f
|
||||
latitude = point_data['latitude'].to_f
|
||||
attributes['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||
end
|
||||
end
|
||||
end
|
||||
27
app/services/users/import_data/settings.rb
Normal file
27
app/services/users/import_data/settings.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Settings
|
||||
def initialize(user, settings_data)
|
||||
@user = user
|
||||
@settings_data = settings_data
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless settings_data.is_a?(Hash)
|
||||
|
||||
Rails.logger.info "Importing settings for user: #{user.email}"
|
||||
|
||||
# Merge imported settings with existing settings
|
||||
current_settings = user.settings || {}
|
||||
updated_settings = current_settings.merge(settings_data)
|
||||
|
||||
user.update!(settings: updated_settings)
|
||||
|
||||
Rails.logger.info "Settings import completed"
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :settings_data
|
||||
end
|
||||
48
app/services/users/import_data/stats.rb
Normal file
48
app/services/users/import_data/stats.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Stats
|
||||
def initialize(user, stats_data)
|
||||
@user = user
|
||||
@stats_data = stats_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless stats_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{stats_data.size} stats for user: #{user.email}"
|
||||
|
||||
stats_created = 0
|
||||
|
||||
stats_data.each do |stat_data|
|
||||
next unless stat_data.is_a?(Hash)
|
||||
|
||||
# Check if stat already exists (match by year and month)
|
||||
existing_stat = user.stats.find_by(
|
||||
year: stat_data['year'],
|
||||
month: stat_data['month']
|
||||
)
|
||||
|
||||
if existing_stat
|
||||
Rails.logger.debug "Stat already exists: #{stat_data['year']}-#{stat_data['month']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new stat
|
||||
stat_attributes = stat_data.except('created_at', 'updated_at')
|
||||
stat = user.stats.create!(stat_attributes)
|
||||
stats_created += 1
|
||||
|
||||
Rails.logger.debug "Created stat: #{stat.year}-#{stat.month}"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create stat: #{e.message}"
|
||||
next
|
||||
end
|
||||
|
||||
Rails.logger.info "Stats import completed. Created: #{stats_created}"
|
||||
stats_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :stats_data
|
||||
end
|
||||
49
app/services/users/import_data/trips.rb
Normal file
49
app/services/users/import_data/trips.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Trips
|
||||
def initialize(user, trips_data)
|
||||
@user = user
|
||||
@trips_data = trips_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless trips_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{trips_data.size} trips for user: #{user.email}"
|
||||
|
||||
trips_created = 0
|
||||
|
||||
trips_data.each do |trip_data|
|
||||
next unless trip_data.is_a?(Hash)
|
||||
|
||||
# Check if trip already exists (match by name and timestamps)
|
||||
existing_trip = user.trips.find_by(
|
||||
name: trip_data['name'],
|
||||
started_at: trip_data['started_at'],
|
||||
ended_at: trip_data['ended_at']
|
||||
)
|
||||
|
||||
if existing_trip
|
||||
Rails.logger.debug "Trip already exists: #{trip_data['name']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new trip
|
||||
trip_attributes = trip_data.except('created_at', 'updated_at')
|
||||
trip = user.trips.create!(trip_attributes)
|
||||
trips_created += 1
|
||||
|
||||
Rails.logger.debug "Created trip: #{trip.name}"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create trip: #{e.message}"
|
||||
next
|
||||
end
|
||||
|
||||
Rails.logger.info "Trips import completed. Created: #{trips_created}"
|
||||
trips_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :trips_data
|
||||
end
|
||||
90
app/services/users/import_data/visits.rb
Normal file
90
app/services/users/import_data/visits.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Users::ImportData::Visits
|
||||
def initialize(user, visits_data)
|
||||
@user = user
|
||||
@visits_data = visits_data
|
||||
end
|
||||
|
||||
def call
|
||||
return 0 unless visits_data.is_a?(Array)
|
||||
|
||||
Rails.logger.info "Importing #{visits_data.size} visits for user: #{user.email}"
|
||||
|
||||
visits_created = 0
|
||||
|
||||
visits_data.each do |visit_data|
|
||||
next unless visit_data.is_a?(Hash)
|
||||
|
||||
# Check if visit already exists (match by name, timestamps, and place reference)
|
||||
existing_visit = find_existing_visit(visit_data)
|
||||
|
||||
if existing_visit
|
||||
Rails.logger.debug "Visit already exists: #{visit_data['name']}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create new visit
|
||||
begin
|
||||
visit_record = create_visit_record(visit_data)
|
||||
visits_created += 1
|
||||
Rails.logger.debug "Created visit: #{visit_record.name}"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create visit: #{e.message}"
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "Visits import completed. Created: #{visits_created}"
|
||||
visits_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :visits_data
|
||||
|
||||
def find_existing_visit(visit_data)
|
||||
user.visits.find_by(
|
||||
name: visit_data['name'],
|
||||
started_at: visit_data['started_at'],
|
||||
ended_at: visit_data['ended_at']
|
||||
)
|
||||
end
|
||||
|
||||
def create_visit_record(visit_data)
|
||||
visit_attributes = prepare_visit_attributes(visit_data)
|
||||
user.visits.create!(visit_attributes)
|
||||
end
|
||||
|
||||
def prepare_visit_attributes(visit_data)
|
||||
attributes = visit_data.except('place_reference')
|
||||
|
||||
# Find and assign place if referenced
|
||||
if visit_data['place_reference']
|
||||
place = find_referenced_place(visit_data['place_reference'])
|
||||
attributes[:place] = place if place
|
||||
end
|
||||
|
||||
attributes
|
||||
end
|
||||
|
||||
def find_referenced_place(place_reference)
|
||||
return nil unless place_reference.is_a?(Hash)
|
||||
|
||||
name = place_reference['name']
|
||||
latitude = place_reference['latitude'].to_f
|
||||
longitude = place_reference['longitude'].to_f
|
||||
|
||||
# Find place by name and coordinates (global search since places are not user-specific)
|
||||
place = Place.find_by(name: name) ||
|
||||
Place.where("latitude = ? AND longitude = ?", latitude, longitude).first
|
||||
|
||||
if place
|
||||
Rails.logger.debug "Found referenced place: #{name}"
|
||||
else
|
||||
Rails.logger.warn "Referenced place not found: #{name} (#{latitude}, #{longitude})"
|
||||
end
|
||||
|
||||
place
|
||||
end
|
||||
end
|
||||
@@ -64,8 +64,33 @@
|
||||
<div class="divider"></div>
|
||||
<p class='mt-3 flex flex-col gap-2'>
|
||||
<%= link_to "Export my data", export_settings_users_path, class: 'btn btn-primary' %>
|
||||
<%= link_to "Import my data", import_settings_users_path, class: 'btn btn-primary' %>
|
||||
<button class='btn btn-primary' onclick="import_modal.showModal()">Import my data</button>
|
||||
</p>
|
||||
|
||||
<!-- Import Data Modal -->
|
||||
<dialog id="import_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Import your data</h3>
|
||||
<p class="mb-4 text-sm text-gray-600">Upload a ZIP file containing your exported Dawarich data to restore your points, trips, and settings.</p>
|
||||
|
||||
<%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: { turbo: false } do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :archive, class: 'label' do %>
|
||||
<span class="label-text">Select ZIP archive</span>
|
||||
<% end %>
|
||||
<%= f.file_field :archive, accept: '.zip', required: true, class: 'file-input file-input-bordered w-full' %>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<%= f.submit "Import Data", class: 'btn btn-primary', data: { disable_with: 'Importing...' } %>
|
||||
<button type="button" class="btn" onclick="import_modal.close()">Cancel</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<% if DawarichSettings.store_geodata? %>
|
||||
<th>Reverse geocoded points</th>
|
||||
<% end %>
|
||||
<th>Status</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -88,7 +88,8 @@ Rails.application.configure do
|
||||
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: hosts.first, port: 3000 }
|
||||
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] || hosts.first }
|
||||
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https'
|
||||
|
||||
@@ -103,7 +103,7 @@ Rails.application.configure do
|
||||
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: hosts.first, port: 3000 }
|
||||
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] }
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
|
||||
10
db/migrate/20250627184017_add_status_to_imports.rb
Normal file
10
db/migrate/20250627184017_add_status_to_imports.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddStatusToImports < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :imports, :status, :integer, default: 0, null: false
|
||||
add_index :imports, :status, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
17
db/schema.rb
generated
17
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
@@ -107,7 +107,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||
t.integer "processed", default: 0
|
||||
t.jsonb "raw_data"
|
||||
t.integer "points_count", default: 0
|
||||
t.integer "status", default: 0, null: false
|
||||
t.index ["source"], name: "index_imports_on_source"
|
||||
t.index ["status"], name: "index_imports_on_status"
|
||||
t.index ["user_id"], name: "index_imports_on_user_id"
|
||||
end
|
||||
|
||||
@@ -230,6 +232,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||
t.index ["user_id"], name: "index_trips_on_user_id"
|
||||
end
|
||||
|
||||
create_table "user_data_imports", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.string "archive_file_name"
|
||||
t.text "error_message"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["status"], name: "index_user_data_imports_on_status"
|
||||
t.index ["user_id", "created_at"], name: "index_user_data_imports_on_user_id_and_created_at"
|
||||
t.index ["user_id"], name: "index_user_data_imports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "email", default: "", null: false
|
||||
t.string "encrypted_password", default: "", null: false
|
||||
@@ -282,6 +296,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_25_185030) do
|
||||
add_foreign_key "points", "visits"
|
||||
add_foreign_key "stats", "users"
|
||||
add_foreign_key "trips", "users"
|
||||
add_foreign_key "user_data_imports", "users"
|
||||
add_foreign_key "visits", "areas"
|
||||
add_foreign_key "visits", "places"
|
||||
add_foreign_key "visits", "users"
|
||||
|
||||
183
spec/jobs/users/import_data_job_spec.rb
Normal file
183
spec/jobs/users/import_data_job_spec.rb
Normal file
@@ -0,0 +1,183 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportDataJob, type: :job do
|
||||
let(:user) { create(:user) }
|
||||
let(:import) { create(:import, user: user, source: :user_data_archive, name: 'test_export.zip') }
|
||||
let(:archive_path) { Rails.root.join('tmp', 'test_export.zip') }
|
||||
let(:job) { described_class.new }
|
||||
|
||||
before do
|
||||
# Create a mock ZIP file
|
||||
FileUtils.touch(archive_path)
|
||||
|
||||
# Mock the import file attachment
|
||||
allow(import).to receive(:file).and_return(
|
||||
double('ActiveStorage::Attached::One',
|
||||
download: proc { |&block|
|
||||
File.read(archive_path).each_char { |c| block.call(c) }
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
after do
|
||||
FileUtils.rm_f(archive_path) if File.exist?(archive_path)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when import is successful' do
|
||||
before do
|
||||
# Mock the import service
|
||||
import_service = instance_double(Users::ImportData)
|
||||
allow(Users::ImportData).to receive(:new).and_return(import_service)
|
||||
allow(import_service).to receive(:import).and_return({
|
||||
settings_updated: true,
|
||||
areas_created: 2,
|
||||
places_created: 3,
|
||||
imports_created: 1,
|
||||
exports_created: 1,
|
||||
trips_created: 2,
|
||||
stats_created: 1,
|
||||
notifications_created: 2,
|
||||
visits_created: 4,
|
||||
points_created: 1000,
|
||||
files_restored: 7
|
||||
})
|
||||
|
||||
# Mock file operations
|
||||
allow(File).to receive(:exist?).and_return(true)
|
||||
allow(File).to receive(:delete)
|
||||
allow(Rails.logger).to receive(:info)
|
||||
end
|
||||
|
||||
it 'calls the import service with correct parameters' do
|
||||
expect(Users::ImportData).to receive(:new).with(user, anything)
|
||||
|
||||
job.perform(import.id)
|
||||
end
|
||||
|
||||
it 'calls import on the service' do
|
||||
import_service = instance_double(Users::ImportData)
|
||||
allow(Users::ImportData).to receive(:new).and_return(import_service)
|
||||
expect(import_service).to receive(:import)
|
||||
|
||||
job.perform(import.id)
|
||||
end
|
||||
|
||||
it 'completes successfully without updating import status' do
|
||||
expect(import).not_to receive(:update!)
|
||||
|
||||
job.perform(import.id)
|
||||
end
|
||||
|
||||
it 'does not create error notifications when successful' do
|
||||
expect(::Notifications::Create).not_to receive(:new)
|
||||
|
||||
job.perform(import.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when import fails' do
|
||||
let(:error_message) { 'Import failed due to invalid archive' }
|
||||
let(:error) { StandardError.new(error_message) }
|
||||
|
||||
before do
|
||||
# Mock the import service to raise an error
|
||||
import_service = instance_double(Users::ImportData)
|
||||
allow(Users::ImportData).to receive(:new).and_return(import_service)
|
||||
allow(import_service).to receive(:import).and_raise(error)
|
||||
|
||||
# Mock notification creation
|
||||
notification_service = instance_double(::Notifications::Create, call: true)
|
||||
allow(::Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
|
||||
# Mock file operations
|
||||
allow(File).to receive(:exist?).and_return(true)
|
||||
allow(File).to receive(:delete)
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
# Mock ExceptionReporter
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
end
|
||||
|
||||
it 'reports the error to ExceptionReporter' do
|
||||
expect(ExceptionReporter).to receive(:call).with(error, "Import job failed for user #{user.id}")
|
||||
|
||||
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'does not update import status on failure' do
|
||||
expect(import).not_to receive(:update!)
|
||||
|
||||
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'creates a failure notification for the user' do
|
||||
expect(::Notifications::Create).to receive(:new).with(
|
||||
user: user,
|
||||
title: 'Data import failed',
|
||||
content: "Your data import failed with error: #{error_message}. Please check the archive format and try again.",
|
||||
kind: :error
|
||||
)
|
||||
|
||||
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 're-raises the error' do
|
||||
expect { job.perform(import.id) }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when import does not exist' do
|
||||
let(:non_existent_import_id) { 999999 }
|
||||
|
||||
it 'raises ActiveRecord::RecordNotFound' do
|
||||
expect { job.perform(non_existent_import_id) }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'does not create a notification when import is not found' do
|
||||
expect(::Notifications::Create).not_to receive(:new)
|
||||
|
||||
expect { job.perform(non_existent_import_id) }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when archive file download fails' do
|
||||
let(:error_message) { 'File download error' }
|
||||
let(:error) { StandardError.new(error_message) }
|
||||
|
||||
before do
|
||||
# Mock file download to fail
|
||||
allow(import).to receive(:file).and_return(
|
||||
double('ActiveStorage::Attached::One', download: proc { raise error })
|
||||
)
|
||||
|
||||
# Mock notification creation
|
||||
notification_service = instance_double(::Notifications::Create, call: true)
|
||||
allow(::Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
end
|
||||
|
||||
it 'creates notification with the correct user object' do
|
||||
notification_service = instance_double(::Notifications::Create, call: true)
|
||||
expect(::Notifications::Create).to receive(:new).with(
|
||||
user: user,
|
||||
title: 'Data import failed',
|
||||
content: a_string_matching(/Your data import failed with error:.*Please check the archive format and try again\./),
|
||||
kind: :error
|
||||
).and_return(notification_service)
|
||||
|
||||
expect(notification_service).to receive(:call)
|
||||
|
||||
expect { job.perform(import.id) }.to raise_error(StandardError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is queued in the imports queue' do
|
||||
expect(described_class.queue_name).to eq('imports')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,8 @@ RSpec.describe Import, type: :model do
|
||||
gpx: 4,
|
||||
immich_api: 5,
|
||||
geojson: 6,
|
||||
photoprism_api: 7
|
||||
photoprism_api: 7,
|
||||
user_data_archive: 8
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,6 +50,8 @@ RSpec.describe Users::ExportData::Points, type: :service do
|
||||
course: 45.5,
|
||||
course_accuracy: 2.5,
|
||||
external_track_id: 'ext-123',
|
||||
longitude: -74.006,
|
||||
latitude: 40.7128,
|
||||
lonlat: 'POINT(-74.006 40.7128)'
|
||||
)
|
||||
end
|
||||
@@ -57,6 +59,8 @@ RSpec.describe Users::ExportData::Points, type: :service do
|
||||
create(:point,
|
||||
user: user,
|
||||
timestamp: 1640995260,
|
||||
longitude: -73.9857,
|
||||
latitude: 40.7484,
|
||||
lonlat: 'POINT(-73.9857 40.7484)'
|
||||
)
|
||||
end
|
||||
@@ -211,5 +215,54 @@ RSpec.describe Users::ExportData::Points, type: :service do
|
||||
expect(subject.size).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when points have missing coordinate data' do
|
||||
let!(:point_with_lonlat_only) do
|
||||
# Point with lonlat but missing individual coordinates
|
||||
point = create(:point, user: user, lonlat: 'POINT(10.0 50.0)', external_track_id: 'lonlat-only')
|
||||
# Clear individual coordinate fields to simulate legacy data
|
||||
point.update_columns(longitude: nil, latitude: nil)
|
||||
point
|
||||
end
|
||||
|
||||
let!(:point_with_coordinates_only) do
|
||||
# Point with coordinates but missing lonlat
|
||||
point = create(:point, user: user, longitude: 15.0, latitude: 55.0, external_track_id: 'coords-only')
|
||||
# Clear lonlat field to simulate missing geometry
|
||||
point.update_columns(lonlat: nil)
|
||||
point
|
||||
end
|
||||
|
||||
let!(:point_without_coordinates) do
|
||||
# Point with no coordinate data at all
|
||||
point = create(:point, user: user, external_track_id: 'no-coords')
|
||||
point.update_columns(longitude: nil, latitude: nil, lonlat: nil)
|
||||
point
|
||||
end
|
||||
|
||||
it 'includes all coordinate fields for points with lonlat only' do
|
||||
point_data = subject.find { |p| p['external_track_id'] == 'lonlat-only' }
|
||||
|
||||
expect(point_data).to be_present
|
||||
expect(point_data['lonlat']).to be_present
|
||||
expect(point_data['longitude']).to eq(10.0)
|
||||
expect(point_data['latitude']).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'includes all coordinate fields for points with coordinates only' do
|
||||
point_data = subject.find { |p| p['external_track_id'] == 'coords-only' }
|
||||
|
||||
expect(point_data).to be_present
|
||||
expect(point_data['lonlat']).to eq('POINT(15.0 55.0)')
|
||||
expect(point_data['longitude']).to eq(15.0)
|
||||
expect(point_data['latitude']).to eq(55.0)
|
||||
end
|
||||
|
||||
it 'skips points without any coordinate data' do
|
||||
point_data = subject.find { |p| p['external_track_id'] == 'no-coords' }
|
||||
|
||||
expect(point_data).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,8 +39,18 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
# Mock user settings
|
||||
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
|
||||
|
||||
# Mock user associations for counting (needed before error occurs)
|
||||
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||
allow(user).to receive(:places).and_return(double(count: 20))
|
||||
|
||||
# Mock Export creation and file attachment
|
||||
exports_double = double('Exports')
|
||||
exports_double = double('Exports', count: 3)
|
||||
allow(user).to receive(:exports).and_return(exports_double)
|
||||
allow(exports_double).to receive(:create!).and_return(export_record)
|
||||
allow(export_record).to receive(:update!)
|
||||
@@ -137,6 +147,22 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
result = service.export
|
||||
expect(result).to eq(export_record)
|
||||
end
|
||||
|
||||
it 'calculates entity counts correctly' do
|
||||
counts = service.send(:calculate_entity_counts)
|
||||
|
||||
expect(counts).to eq({
|
||||
areas: 5,
|
||||
imports: 12,
|
||||
exports: 3,
|
||||
trips: 8,
|
||||
stats: 24,
|
||||
notifications: 10,
|
||||
points: 15000,
|
||||
visits: 45,
|
||||
places: 20
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs during export' do
|
||||
@@ -145,7 +171,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
|
||||
before do
|
||||
# Mock Export creation first
|
||||
exports_double = double('Exports')
|
||||
exports_double = double('Exports', count: 3)
|
||||
allow(user).to receive(:exports).and_return(exports_double)
|
||||
allow(exports_double).to receive(:create!).and_return(export_record)
|
||||
allow(export_record).to receive(:update!)
|
||||
@@ -153,10 +179,21 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
# Mock user settings and other dependencies that are needed before the error
|
||||
allow(user).to receive(:safe_settings).and_return(double(settings: { theme: 'dark' }))
|
||||
|
||||
# Mock user associations for counting
|
||||
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||
# exports already mocked above
|
||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||
allow(user).to receive(:places).and_return(double(count: 20))
|
||||
|
||||
# Then set up the error condition - make it happen during the JSON writing step
|
||||
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_raise(StandardError, error_message)
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
|
||||
# Mock cleanup method and pathname existence
|
||||
allow(service).to receive(:cleanup_temporary_files)
|
||||
@@ -169,8 +206,8 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
expect { service.export }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with("Export failed: #{error_message}")
|
||||
it 'reports the error via ExceptionReporter' do
|
||||
expect(ExceptionReporter).to receive(:call).with(an_instance_of(StandardError), 'Export failed')
|
||||
|
||||
expect { service.export }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
@@ -188,7 +225,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
|
||||
context 'when export record creation fails' do
|
||||
before do
|
||||
exports_double = double('Exports')
|
||||
exports_double = double('Exports', count: 3)
|
||||
allow(user).to receive(:exports).and_return(exports_double)
|
||||
allow(exports_double).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
@@ -203,7 +240,7 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
|
||||
before do
|
||||
# Mock Export creation
|
||||
exports_double = double('Exports')
|
||||
exports_double = double('Exports', count: 3)
|
||||
allow(user).to receive(:exports).and_return(exports_double)
|
||||
allow(exports_double).to receive(:create!).and_return(export_record)
|
||||
allow(export_record).to receive(:update!)
|
||||
@@ -221,6 +258,18 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
allow(Users::ExportData::Places).to receive(:new).and_return(double(call: []))
|
||||
|
||||
allow(user).to receive(:safe_settings).and_return(double(settings: {}))
|
||||
|
||||
# Mock user associations for counting
|
||||
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||
# exports already mocked above
|
||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||
allow(user).to receive(:places).and_return(double(count: 20))
|
||||
|
||||
allow(File).to receive(:open).and_call_original
|
||||
allow(File).to receive(:open).with(export_directory.join('data.json'), 'w').and_yield(StringIO.new)
|
||||
|
||||
@@ -292,11 +341,11 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
before do
|
||||
allow(File).to receive(:directory?).and_return(true)
|
||||
allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
end
|
||||
|
||||
it 'logs the error but does not re-raise' do
|
||||
expect(Rails.logger).to receive(:error).with('Failed to cleanup temporary files: Permission denied')
|
||||
it 'reports the error via ExceptionReporter but does not re-raise' do
|
||||
expect(ExceptionReporter).to receive(:call).with(an_instance_of(StandardError), 'Failed to cleanup temporary files')
|
||||
|
||||
expect { service.send(:cleanup_temporary_files, export_directory) }.not_to raise_error
|
||||
end
|
||||
@@ -314,5 +363,44 @@ RSpec.describe Users::ExportData, type: :service do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_entity_counts' do
|
||||
before do
|
||||
# Mock user associations for counting
|
||||
allow(user).to receive(:areas).and_return(double(count: 5))
|
||||
allow(user).to receive(:imports).and_return(double(count: 12))
|
||||
allow(user).to receive(:exports).and_return(double(count: 3))
|
||||
allow(user).to receive(:trips).and_return(double(count: 8))
|
||||
allow(user).to receive(:stats).and_return(double(count: 24))
|
||||
allow(user).to receive(:notifications).and_return(double(count: 10))
|
||||
allow(user).to receive(:tracked_points).and_return(double(count: 15000))
|
||||
allow(user).to receive(:visits).and_return(double(count: 45))
|
||||
allow(user).to receive(:places).and_return(double(count: 20))
|
||||
allow(Rails.logger).to receive(:info)
|
||||
end
|
||||
|
||||
it 'returns correct counts for all entity types' do
|
||||
counts = service.send(:calculate_entity_counts)
|
||||
|
||||
expect(counts).to eq({
|
||||
areas: 5,
|
||||
imports: 12,
|
||||
exports: 3,
|
||||
trips: 8,
|
||||
stats: 24,
|
||||
notifications: 10,
|
||||
points: 15000,
|
||||
visits: 45,
|
||||
places: 20
|
||||
})
|
||||
end
|
||||
|
||||
it 'logs the calculation process' do
|
||||
expect(Rails.logger).to receive(:info).with("Calculating entity counts for export")
|
||||
expect(Rails.logger).to receive(:info).with(/Entity counts:/)
|
||||
|
||||
service.send(:calculate_entity_counts)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
161
spec/services/users/import_data/areas_spec.rb
Normal file
161
spec/services/users/import_data/areas_spec.rb
Normal file
@@ -0,0 +1,161 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Areas, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:areas_data) do
|
||||
[
|
||||
{
|
||||
'name' => 'Home',
|
||||
'latitude' => '40.7128',
|
||||
'longitude' => '-74.0060',
|
||||
'radius' => 100,
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
'updated_at' => '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
'name' => 'Work',
|
||||
'latitude' => '40.7589',
|
||||
'longitude' => '-73.9851',
|
||||
'radius' => 50,
|
||||
'created_at' => '2024-01-02T00:00:00Z',
|
||||
'updated_at' => '2024-01-02T00:00:00Z'
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:service) { described_class.new(user, areas_data) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid areas data' do
|
||||
it 'creates new areas for the user' do
|
||||
expect { service.call }.to change { user.areas.count }.by(2)
|
||||
end
|
||||
|
||||
it 'creates areas with correct attributes' do
|
||||
service.call
|
||||
|
||||
home_area = user.areas.find_by(name: 'Home')
|
||||
expect(home_area).to have_attributes(
|
||||
name: 'Home',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
radius: 100
|
||||
)
|
||||
|
||||
work_area = user.areas.find_by(name: 'Work')
|
||||
expect(work_area).to have_attributes(
|
||||
name: 'Work',
|
||||
latitude: 40.7589,
|
||||
longitude: -73.9851,
|
||||
radius: 50
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the number of areas created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 2 areas for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Areas import completed. Created: 2")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate areas' do
|
||||
before do
|
||||
# Create an existing area with same name and coordinates
|
||||
user.areas.create!(
|
||||
name: 'Home',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
radius: 100
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips duplicate areas' do
|
||||
expect { service.call }.to change { user.areas.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Area already exists: Home")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns only the count of newly created areas' do
|
||||
result = service.call
|
||||
expect(result).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid area data' do
|
||||
let(:areas_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Area', 'latitude' => '40.7128', 'longitude' => '-74.0060', 'radius' => 100 },
|
||||
'invalid_data',
|
||||
{ 'name' => 'Another Valid Area', 'latitude' => '40.7589', 'longitude' => '-73.9851', 'radius' => 50 }
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid entries and imports valid ones' do
|
||||
expect { service.call }.to change { user.areas.count }.by(2)
|
||||
end
|
||||
|
||||
it 'returns the count of valid areas created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil areas data' do
|
||||
let(:areas_data) { nil }
|
||||
|
||||
it 'does not create any areas' do
|
||||
expect { service.call }.not_to change { user.areas.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-array areas data' do
|
||||
let(:areas_data) { 'invalid_data' }
|
||||
|
||||
it 'does not create any areas' do
|
||||
expect { service.call }.not_to change { user.areas.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty areas data' do
|
||||
let(:areas_data) { [] }
|
||||
|
||||
it 'does not create any areas' do
|
||||
expect { service.call }.not_to change { user.areas.count }
|
||||
end
|
||||
|
||||
it 'logs the import process with 0 count' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 0 areas for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Areas import completed. Created: 0")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
270
spec/services/users/import_data/imports_spec.rb
Normal file
270
spec/services/users/import_data/imports_spec.rb
Normal file
@@ -0,0 +1,270 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Imports, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:files_directory) { Rails.root.join('tmp', 'test_files') }
|
||||
let(:imports_data) do
|
||||
[
|
||||
{
|
||||
'name' => '2023_MARCH.json',
|
||||
'source' => 'google_semantic_history',
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
'updated_at' => '2024-01-01T00:00:00Z',
|
||||
'processed' => true,
|
||||
'file_name' => 'import_1_2023_MARCH.json',
|
||||
'original_filename' => '2023_MARCH.json',
|
||||
'file_size' => 2048576,
|
||||
'content_type' => 'application/json'
|
||||
},
|
||||
{
|
||||
'name' => '2023_APRIL.json',
|
||||
'source' => 'owntracks',
|
||||
'created_at' => '2024-01-02T00:00:00Z',
|
||||
'updated_at' => '2024-01-02T00:00:00Z',
|
||||
'processed' => false,
|
||||
'file_name' => 'import_2_2023_APRIL.json',
|
||||
'original_filename' => '2023_APRIL.json',
|
||||
'file_size' => 1048576,
|
||||
'content_type' => 'application/json'
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:service) { described_class.new(user, imports_data, files_directory) }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(files_directory)
|
||||
# Create mock files
|
||||
File.write(files_directory.join('import_1_2023_MARCH.json'), '{"test": "data"}')
|
||||
File.write(files_directory.join('import_2_2023_APRIL.json'), '{"more": "data"}')
|
||||
|
||||
# Mock the Import job to prevent it from being enqueued
|
||||
allow(Import::ProcessJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
after do
|
||||
FileUtils.rm_rf(files_directory) if files_directory.exist?
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid imports data' do
|
||||
it 'creates new imports for the user' do
|
||||
expect { service.call }.to change { user.imports.count }.by(2)
|
||||
end
|
||||
|
||||
it 'creates imports with correct attributes' do
|
||||
service.call
|
||||
|
||||
march_import = user.imports.find_by(name: '2023_MARCH.json')
|
||||
expect(march_import).to have_attributes(
|
||||
name: '2023_MARCH.json',
|
||||
source: 'google_semantic_history',
|
||||
processed: 1
|
||||
)
|
||||
|
||||
april_import = user.imports.find_by(name: '2023_APRIL.json')
|
||||
expect(april_import).to have_attributes(
|
||||
name: '2023_APRIL.json',
|
||||
source: 'owntracks',
|
||||
processed: 0
|
||||
)
|
||||
end
|
||||
|
||||
it 'attaches files to the imports' do
|
||||
service.call
|
||||
|
||||
march_import = user.imports.find_by(name: '2023_MARCH.json')
|
||||
expect(march_import.file).to be_attached
|
||||
expect(march_import.file.filename.to_s).to eq('2023_MARCH.json')
|
||||
expect(march_import.file.content_type).to eq('application/json')
|
||||
|
||||
april_import = user.imports.find_by(name: '2023_APRIL.json')
|
||||
expect(april_import.file).to be_attached
|
||||
expect(april_import.file.filename.to_s).to eq('2023_APRIL.json')
|
||||
expect(april_import.file.content_type).to eq('application/json')
|
||||
end
|
||||
|
||||
it 'returns the number of imports and files created' do
|
||||
imports_created, files_restored = service.call
|
||||
expect(imports_created).to eq(2)
|
||||
expect(files_restored).to eq(2)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
allow(Rails.logger).to receive(:info) # Allow all info logs (including ActiveStorage)
|
||||
expect(Rails.logger).to receive(:info).with("Importing 2 imports for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Imports import completed. Created: 2, Files restored: 2")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate imports' do
|
||||
before do
|
||||
# Create an existing import with same name, source, and created_at
|
||||
user.imports.create!(
|
||||
name: '2023_MARCH.json',
|
||||
source: 'google_semantic_history',
|
||||
created_at: Time.parse('2024-01-01T00:00:00Z')
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips duplicate imports' do
|
||||
expect { service.call }.to change { user.imports.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Import already exists: 2023_MARCH.json")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns only the count of newly created imports' do
|
||||
imports_created, files_restored = service.call
|
||||
expect(imports_created).to eq(1)
|
||||
expect(files_restored).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing files' do
|
||||
before do
|
||||
FileUtils.rm_f(files_directory.join('import_1_2023_MARCH.json'))
|
||||
end
|
||||
|
||||
it 'creates imports but logs file errors' do
|
||||
expect(Rails.logger).to receive(:warn).with(/Import file not found/)
|
||||
|
||||
imports_created, files_restored = service.call
|
||||
expect(imports_created).to eq(2)
|
||||
expect(files_restored).to eq(1) # Only one file was successfully restored
|
||||
end
|
||||
|
||||
it 'creates imports without file attachments for missing files' do
|
||||
service.call
|
||||
|
||||
march_import = user.imports.find_by(name: '2023_MARCH.json')
|
||||
expect(march_import.file).not_to be_attached
|
||||
end
|
||||
end
|
||||
|
||||
context 'with imports that have no files (null file_name)' do
|
||||
let(:imports_data) do
|
||||
[
|
||||
{
|
||||
'name' => 'No File Import',
|
||||
'source' => 'gpx',
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
'processed' => true,
|
||||
'file_name' => nil,
|
||||
'original_filename' => nil
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'creates imports without attempting file restoration' do
|
||||
expect { service.call }.to change { user.imports.count }.by(1)
|
||||
end
|
||||
|
||||
it 'returns correct counts' do
|
||||
imports_created, files_restored = service.call
|
||||
expect(imports_created).to eq(1)
|
||||
expect(files_restored).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid import data' do
|
||||
let(:imports_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Import', 'source' => 'owntracks' },
|
||||
'invalid_data',
|
||||
{ 'name' => 'Another Valid Import', 'source' => 'gpx' }
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid entries and imports valid ones' do
|
||||
expect { service.call }.to change { user.imports.count }.by(2)
|
||||
end
|
||||
|
||||
it 'returns the count of valid imports created' do
|
||||
imports_created, files_restored = service.call
|
||||
expect(imports_created).to eq(2)
|
||||
expect(files_restored).to eq(0) # No files for these imports
|
||||
end
|
||||
end
|
||||
|
||||
context 'with validation errors' do
|
||||
let(:imports_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Import', 'source' => 'owntracks' },
|
||||
{ 'source' => 'owntracks' }, # missing name
|
||||
{ 'name' => 'Missing Source Import' } # missing source
|
||||
]
|
||||
end
|
||||
|
||||
it 'only creates valid imports' do
|
||||
expect { service.call }.to change { user.imports.count }.by(2)
|
||||
|
||||
# Verify only the valid imports were created (name is required, source defaults to first enum)
|
||||
created_imports = user.imports.pluck(:name, :source)
|
||||
expect(created_imports).to contain_exactly(
|
||||
['Valid Import', 'owntracks'],
|
||||
['Missing Source Import', 'google_semantic_history']
|
||||
)
|
||||
end
|
||||
|
||||
it 'logs validation errors' do
|
||||
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil imports data' do
|
||||
let(:imports_data) { nil }
|
||||
|
||||
it 'does not create any imports' do
|
||||
expect { service.call }.not_to change { user.imports.count }
|
||||
end
|
||||
|
||||
it 'returns [0, 0]' do
|
||||
result = service.call
|
||||
expect(result).to eq([0, 0])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-array imports data' do
|
||||
let(:imports_data) { 'invalid_data' }
|
||||
|
||||
it 'does not create any imports' do
|
||||
expect { service.call }.not_to change { user.imports.count }
|
||||
end
|
||||
|
||||
it 'returns [0, 0]' do
|
||||
result = service.call
|
||||
expect(result).to eq([0, 0])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty imports data' do
|
||||
let(:imports_data) { [] }
|
||||
|
||||
it 'does not create any imports' do
|
||||
expect { service.call }.not_to change { user.imports.count }
|
||||
end
|
||||
|
||||
it 'logs the import process with 0 count' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 0 imports for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Imports import completed. Created: 0, Files restored: 0")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns [0, 0]' do
|
||||
result = service.call
|
||||
expect(result).to eq([0, 0])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
181
spec/services/users/import_data/notifications_spec.rb
Normal file
181
spec/services/users/import_data/notifications_spec.rb
Normal file
@@ -0,0 +1,181 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Notifications, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:notifications_data) do
|
||||
[
|
||||
{
|
||||
'kind' => 'info',
|
||||
'title' => 'Import completed',
|
||||
'content' => 'Your data import has been processed successfully',
|
||||
'read_at' => '2024-01-01T12:30:00Z',
|
||||
'created_at' => '2024-01-01T12:00:00Z',
|
||||
'updated_at' => '2024-01-01T12:30:00Z'
|
||||
},
|
||||
{
|
||||
'kind' => 'error',
|
||||
'title' => 'Import failed',
|
||||
'content' => 'There was an error processing your data',
|
||||
'read_at' => nil,
|
||||
'created_at' => '2024-01-02T10:00:00Z',
|
||||
'updated_at' => '2024-01-02T10:00:00Z'
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:service) { described_class.new(user, notifications_data) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid notifications data' do
|
||||
it 'creates new notifications for the user' do
|
||||
expect { service.call }.to change { user.notifications.count }.by(2)
|
||||
end
|
||||
|
||||
it 'creates notifications with correct attributes' do
|
||||
service.call
|
||||
|
||||
import_notification = user.notifications.find_by(title: 'Import completed')
|
||||
expect(import_notification).to have_attributes(
|
||||
kind: 'info',
|
||||
title: 'Import completed',
|
||||
content: 'Your data import has been processed successfully',
|
||||
read_at: Time.parse('2024-01-01T12:30:00Z')
|
||||
)
|
||||
|
||||
error_notification = user.notifications.find_by(title: 'Import failed')
|
||||
expect(error_notification).to have_attributes(
|
||||
kind: 'error',
|
||||
title: 'Import failed',
|
||||
content: 'There was an error processing your data',
|
||||
read_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the number of notifications created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 2 notifications for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Notifications import completed. Created: 2")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate notifications' do
|
||||
before do
|
||||
# Create an existing notification with same title, content, and created_at
|
||||
user.notifications.create!(
|
||||
kind: 'info',
|
||||
title: 'Import completed',
|
||||
content: 'Your data import has been processed successfully',
|
||||
created_at: Time.parse('2024-01-01T12:00:00Z')
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips duplicate notifications' do
|
||||
expect { service.call }.to change { user.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Notification already exists: Import completed")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns only the count of newly created notifications' do
|
||||
result = service.call
|
||||
expect(result).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid notification data' do
|
||||
let(:notifications_data) do
|
||||
[
|
||||
{ 'kind' => 'info', 'title' => 'Valid Notification', 'content' => 'Valid content' },
|
||||
'invalid_data',
|
||||
{ 'kind' => 'error', 'title' => 'Another Valid Notification', 'content' => 'Another valid content' }
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid entries and imports valid ones' do
|
||||
expect { service.call }.to change { user.notifications.count }.by(2)
|
||||
end
|
||||
|
||||
it 'returns the count of valid notifications created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with validation errors' do
|
||||
let(:notifications_data) do
|
||||
[
|
||||
{ 'kind' => 'info', 'title' => 'Valid Notification', 'content' => 'Valid content' },
|
||||
{ 'kind' => 'info', 'content' => 'Missing title' }, # missing title
|
||||
{ 'kind' => 'error', 'title' => 'Missing content' } # missing content
|
||||
]
|
||||
end
|
||||
|
||||
it 'only creates valid notifications' do
|
||||
expect { service.call }.to change { user.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs validation errors' do
|
||||
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil notifications data' do
|
||||
let(:notifications_data) { nil }
|
||||
|
||||
it 'does not create any notifications' do
|
||||
expect { service.call }.not_to change { user.notifications.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-array notifications data' do
|
||||
let(:notifications_data) { 'invalid_data' }
|
||||
|
||||
it 'does not create any notifications' do
|
||||
expect { service.call }.not_to change { user.notifications.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty notifications data' do
|
||||
let(:notifications_data) { [] }
|
||||
|
||||
it 'does not create any notifications' do
|
||||
expect { service.call }.not_to change { user.notifications.count }
|
||||
end
|
||||
|
||||
it 'logs the import process with 0 count' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 0 notifications for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Notifications import completed. Created: 0")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
216
spec/services/users/import_data/places_spec.rb
Normal file
216
spec/services/users/import_data/places_spec.rb
Normal file
@@ -0,0 +1,216 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Places, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:places_data) do
|
||||
[
|
||||
{
|
||||
'name' => 'Home',
|
||||
'latitude' => '40.7128',
|
||||
'longitude' => '-74.0060',
|
||||
'source' => 'manual',
|
||||
'geodata' => { 'address' => '123 Main St' },
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
'updated_at' => '2024-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
'name' => 'Office',
|
||||
'latitude' => '40.7589',
|
||||
'longitude' => '-73.9851',
|
||||
'source' => 'photon',
|
||||
'geodata' => { 'properties' => { 'name' => 'Office Building' } },
|
||||
'created_at' => '2024-01-02T00:00:00Z',
|
||||
'updated_at' => '2024-01-02T00:00:00Z'
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:service) { described_class.new(user, places_data) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid places data' do
|
||||
it 'creates new places' do
|
||||
expect { service.call }.to change { Place.count }.by(2)
|
||||
end
|
||||
|
||||
it 'creates places with correct attributes' do
|
||||
service.call
|
||||
|
||||
home_place = Place.find_by(name: 'Home')
|
||||
expect(home_place).to have_attributes(
|
||||
name: 'Home',
|
||||
source: 'manual'
|
||||
)
|
||||
expect(home_place.lat).to be_within(0.0001).of(40.7128)
|
||||
expect(home_place.lon).to be_within(0.0001).of(-74.0060)
|
||||
expect(home_place.geodata).to eq('address' => '123 Main St')
|
||||
|
||||
office_place = Place.find_by(name: 'Office')
|
||||
expect(office_place).to have_attributes(
|
||||
name: 'Office',
|
||||
source: 'photon'
|
||||
)
|
||||
expect(office_place.lat).to be_within(0.0001).of(40.7589)
|
||||
expect(office_place.lon).to be_within(0.0001).of(-73.9851)
|
||||
expect(office_place.geodata).to eq('properties' => { 'name' => 'Office Building' })
|
||||
end
|
||||
|
||||
it 'returns the number of places created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 2 places for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Places import completed. Created: 2")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate places (same name)' do
|
||||
before do
|
||||
# Create an existing place with same name
|
||||
create(:place, name: 'Home',
|
||||
latitude: 40.7128, longitude: -74.0060,
|
||||
lonlat: 'POINT(-74.0060 40.7128)')
|
||||
end
|
||||
|
||||
it 'skips duplicate places' do
|
||||
expect { service.call }.to change { Place.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Place already exists: Home")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns only the count of newly created places' do
|
||||
result = service.call
|
||||
expect(result).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate places (same coordinates)' do
|
||||
before do
|
||||
# Create an existing place with same coordinates but different name
|
||||
create(:place, name: 'Different Name',
|
||||
latitude: 40.7128, longitude: -74.0060,
|
||||
lonlat: 'POINT(-74.0060 40.7128)')
|
||||
end
|
||||
|
||||
it 'skips duplicate places by coordinates' do
|
||||
expect { service.call }.to change { Place.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Place already exists: Home")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with places having same name but different coordinates' do
|
||||
before do
|
||||
create(:place, name: 'Different Place',
|
||||
latitude: 41.0000, longitude: -75.0000,
|
||||
lonlat: 'POINT(-75.0000 41.0000)')
|
||||
end
|
||||
|
||||
it 'creates both places since coordinates and names differ' do
|
||||
expect { service.call }.to change { Place.count }.by(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid place data' do
|
||||
let(:places_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Place', 'latitude' => '40.7128', 'longitude' => '-74.0060' },
|
||||
'invalid_data',
|
||||
{ 'name' => 'Another Valid Place', 'latitude' => '40.7589', 'longitude' => '-73.9851' }
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid entries and imports valid ones' do
|
||||
expect { service.call }.to change { Place.count }.by(2)
|
||||
end
|
||||
|
||||
it 'returns the count of valid places created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing required fields' do
|
||||
let(:places_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Place', 'latitude' => '40.7128', 'longitude' => '-74.0060' },
|
||||
{ 'latitude' => '40.7589', 'longitude' => '-73.9851' }, # missing name
|
||||
{ 'name' => 'Invalid Place', 'longitude' => '-73.9851' }, # missing latitude
|
||||
{ 'name' => 'Another Invalid Place', 'latitude' => '40.7589' } # missing longitude
|
||||
]
|
||||
end
|
||||
|
||||
it 'only creates places with all required fields' do
|
||||
expect { service.call }.to change { Place.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs skipped records with missing data' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow all debug logs
|
||||
expect(Rails.logger).to receive(:debug).with(/Skipping place with missing required data/).at_least(:once)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil places data' do
|
||||
let(:places_data) { nil }
|
||||
|
||||
it 'does not create any places' do
|
||||
expect { service.call }.not_to change { Place.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-array places data' do
|
||||
let(:places_data) { 'invalid_data' }
|
||||
|
||||
it 'does not create any places' do
|
||||
expect { service.call }.not_to change { Place.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty places data' do
|
||||
let(:places_data) { [] }
|
||||
|
||||
it 'does not create any places' do
|
||||
expect { service.call }.not_to change { Place.count }
|
||||
end
|
||||
|
||||
it 'logs the import process with 0 count' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 0 places for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Places import completed. Created: 0")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
139
spec/services/users/import_data/points_spec.rb
Normal file
139
spec/services/users/import_data/points_spec.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Points, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:service) { described_class.new(user, points_data) }
|
||||
|
||||
describe '#call' do
|
||||
context 'when importing points with country information' do
|
||||
let(:country) { create(:country, name: 'Germany', iso_a2: 'DE', iso_a3: 'DEU') }
|
||||
let(:points_data) do
|
||||
[
|
||||
{
|
||||
'timestamp' => 1640995200,
|
||||
'lonlat' => 'POINT(13.4050 52.5200)',
|
||||
'city' => 'Berlin',
|
||||
'country' => 'Germany', # String field from export
|
||||
'country_info' => {
|
||||
'name' => 'Germany',
|
||||
'iso_a2' => 'DE',
|
||||
'iso_a3' => 'DEU'
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
country # Create the country
|
||||
end
|
||||
|
||||
it 'creates points without type errors' do
|
||||
expect { service.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'assigns the correct country association' do
|
||||
service.call
|
||||
point = user.tracked_points.last
|
||||
expect(point.country).to eq(country)
|
||||
end
|
||||
|
||||
it 'excludes the string country field from attributes' do
|
||||
service.call
|
||||
point = user.tracked_points.last
|
||||
# The country association should be set, not the string attribute
|
||||
expect(point.read_attribute(:country)).to be_nil
|
||||
expect(point.country).to eq(country)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when country does not exist in database' do
|
||||
let(:points_data) do
|
||||
[
|
||||
{
|
||||
'timestamp' => 1640995200,
|
||||
'lonlat' => 'POINT(13.4050 52.5200)',
|
||||
'city' => 'Berlin',
|
||||
'country' => 'NewCountry',
|
||||
'country_info' => {
|
||||
'name' => 'NewCountry',
|
||||
'iso_a2' => 'NC',
|
||||
'iso_a3' => 'NCO'
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'creates the country and assigns it' do
|
||||
expect { service.call }.to change(Country, :count).by(1)
|
||||
|
||||
point = user.tracked_points.last
|
||||
expect(point.country.name).to eq('NewCountry')
|
||||
expect(point.country.iso_a2).to eq('NC')
|
||||
expect(point.country.iso_a3).to eq('NCO')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when points_data is empty' do
|
||||
let(:points_data) { [] }
|
||||
|
||||
it 'returns 0 without errors' do
|
||||
expect(service.call).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when points_data is not an array' do
|
||||
let(:points_data) { 'invalid' }
|
||||
|
||||
it 'returns 0 without errors' do
|
||||
expect(service.call).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when points have invalid or missing data' do
|
||||
let(:points_data) do
|
||||
[
|
||||
{
|
||||
'timestamp' => 1640995200,
|
||||
'lonlat' => 'POINT(13.4050 52.5200)',
|
||||
'city' => 'Berlin'
|
||||
},
|
||||
{
|
||||
# Missing lonlat but has longitude/latitude (should be reconstructed)
|
||||
'timestamp' => 1640995220,
|
||||
'longitude' => 11.5820,
|
||||
'latitude' => 48.1351,
|
||||
'city' => 'Munich'
|
||||
},
|
||||
{
|
||||
# Missing lonlat and coordinates
|
||||
'timestamp' => 1640995260,
|
||||
'city' => 'Hamburg'
|
||||
},
|
||||
{
|
||||
# Missing timestamp
|
||||
'lonlat' => 'POINT(11.5820 48.1351)',
|
||||
'city' => 'Stuttgart'
|
||||
},
|
||||
{
|
||||
# Invalid lonlat format
|
||||
'timestamp' => 1640995320,
|
||||
'lonlat' => 'invalid format',
|
||||
'city' => 'Frankfurt'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'imports valid points and reconstructs lonlat when needed' do
|
||||
expect(service.call).to eq(2) # Two valid points (original + reconstructed)
|
||||
expect(user.tracked_points.count).to eq(2)
|
||||
|
||||
# Check that lonlat was reconstructed properly
|
||||
munich_point = user.tracked_points.find_by(city: 'Munich')
|
||||
expect(munich_point).to be_present
|
||||
expect(munich_point.lonlat.to_s).to match(/POINT\s*\(11\.582\s+48\.1351\)/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
82
spec/services/users/import_data/settings_spec.rb
Normal file
82
spec/services/users/import_data/settings_spec.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Settings, type: :service do
|
||||
let(:user) { create(:user, settings: { existing_setting: 'value', theme: 'light' }) }
|
||||
let(:settings_data) { { 'theme' => 'dark', 'distance_unit' => 'km', 'new_setting' => 'test' } }
|
||||
let(:service) { described_class.new(user, settings_data) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid settings data' do
|
||||
it 'merges imported settings with existing settings' do
|
||||
expect { service.call }.to change { user.reload.settings }.to(
|
||||
'existing_setting' => 'value',
|
||||
'theme' => 'dark',
|
||||
'distance_unit' => 'km',
|
||||
'new_setting' => 'test'
|
||||
)
|
||||
end
|
||||
|
||||
it 'gives precedence to imported settings over existing ones' do
|
||||
service.call
|
||||
|
||||
expect(user.reload.settings['theme']).to eq('dark')
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing settings for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Settings import completed")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil settings data' do
|
||||
let(:settings_data) { nil }
|
||||
|
||||
it 'does not change user settings' do
|
||||
expect { service.call }.not_to change { user.reload.settings }
|
||||
end
|
||||
|
||||
it 'does not log import process' do
|
||||
expect(Rails.logger).not_to receive(:info)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-hash settings data' do
|
||||
let(:settings_data) { 'invalid_data' }
|
||||
|
||||
it 'does not change user settings' do
|
||||
expect { service.call }.not_to change { user.reload.settings }
|
||||
end
|
||||
|
||||
it 'does not log import process' do
|
||||
expect(Rails.logger).not_to receive(:info)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty settings data' do
|
||||
let(:settings_data) { {} }
|
||||
|
||||
it 'preserves existing settings without adding new ones' do
|
||||
original_settings = user.settings.dup
|
||||
|
||||
service.call
|
||||
|
||||
expect(user.reload.settings).to eq(original_settings)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing settings for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Settings import completed")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
188
spec/services/users/import_data/stats_spec.rb
Normal file
188
spec/services/users/import_data/stats_spec.rb
Normal file
@@ -0,0 +1,188 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Stats, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:stats_data) do
|
||||
[
|
||||
{
|
||||
'year' => 2024,
|
||||
'month' => 1,
|
||||
'distance' => 456.78,
|
||||
'daily_distance' => [[1, 15.2], [2, 23.5], [3, 18.1]],
|
||||
'toponyms' => [
|
||||
{ 'country' => 'United States', 'cities' => [{ 'city' => 'New York' }] }
|
||||
],
|
||||
'created_at' => '2024-02-01T00:00:00Z',
|
||||
'updated_at' => '2024-02-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
'year' => 2024,
|
||||
'month' => 2,
|
||||
'distance' => 321.45,
|
||||
'daily_distance' => [[1, 12.3], [2, 19.8], [3, 25.4]],
|
||||
'toponyms' => [
|
||||
{ 'country' => 'Canada', 'cities' => [{ 'city' => 'Toronto' }] }
|
||||
],
|
||||
'created_at' => '2024-03-01T00:00:00Z',
|
||||
'updated_at' => '2024-03-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:service) { described_class.new(user, stats_data) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid stats data' do
|
||||
it 'creates new stats for the user' do
|
||||
expect { service.call }.to change { user.stats.count }.by(2)
|
||||
end
|
||||
|
||||
it 'creates stats with correct attributes' do
|
||||
service.call
|
||||
|
||||
jan_stats = user.stats.find_by(year: 2024, month: 1)
|
||||
expect(jan_stats).to have_attributes(
|
||||
year: 2024,
|
||||
month: 1,
|
||||
distance: 456
|
||||
)
|
||||
expect(jan_stats.daily_distance).to eq([[1, 15.2], [2, 23.5], [3, 18.1]])
|
||||
expect(jan_stats.toponyms).to eq([{ 'country' => 'United States', 'cities' => [{ 'city' => 'New York' }] }])
|
||||
|
||||
feb_stats = user.stats.find_by(year: 2024, month: 2)
|
||||
expect(feb_stats).to have_attributes(
|
||||
year: 2024,
|
||||
month: 2,
|
||||
distance: 321
|
||||
)
|
||||
expect(feb_stats.daily_distance).to eq([[1, 12.3], [2, 19.8], [3, 25.4]])
|
||||
expect(feb_stats.toponyms).to eq([{ 'country' => 'Canada', 'cities' => [{ 'city' => 'Toronto' }] }])
|
||||
end
|
||||
|
||||
it 'returns the number of stats created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 2 stats for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Stats import completed. Created: 2")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate stats (same year and month)' do
|
||||
before do
|
||||
# Create an existing stat with same year and month
|
||||
user.stats.create!(
|
||||
year: 2024,
|
||||
month: 1,
|
||||
distance: 100.0
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips duplicate stats' do
|
||||
expect { service.call }.to change { user.stats.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Stat already exists: 2024-1")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns only the count of newly created stats' do
|
||||
result = service.call
|
||||
expect(result).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid stat data' do
|
||||
let(:stats_data) do
|
||||
[
|
||||
{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 },
|
||||
'invalid_data',
|
||||
{ 'year' => 2024, 'month' => 2, 'distance' => 321.45 }
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid entries and imports valid ones' do
|
||||
expect { service.call }.to change { user.stats.count }.by(2)
|
||||
end
|
||||
|
||||
it 'returns the count of valid stats created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with validation errors' do
|
||||
let(:stats_data) do
|
||||
[
|
||||
{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 },
|
||||
{ 'month' => 1, 'distance' => 321.45 }, # missing year
|
||||
{ 'year' => 2024, 'distance' => 123.45 } # missing month
|
||||
]
|
||||
end
|
||||
|
||||
it 'only creates valid stats' do
|
||||
expect { service.call }.to change { user.stats.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs validation errors' do
|
||||
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil stats data' do
|
||||
let(:stats_data) { nil }
|
||||
|
||||
it 'does not create any stats' do
|
||||
expect { service.call }.not_to change { user.stats.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-array stats data' do
|
||||
let(:stats_data) { 'invalid_data' }
|
||||
|
||||
it 'does not create any stats' do
|
||||
expect { service.call }.not_to change { user.stats.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty stats data' do
|
||||
let(:stats_data) { [] }
|
||||
|
||||
it 'does not create any stats' do
|
||||
expect { service.call }.not_to change { user.stats.count }
|
||||
end
|
||||
|
||||
it 'logs the import process with 0 count' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 0 stats for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Stats import completed. Created: 0")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
186
spec/services/users/import_data/trips_spec.rb
Normal file
186
spec/services/users/import_data/trips_spec.rb
Normal file
@@ -0,0 +1,186 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData::Trips, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:trips_data) do
|
||||
[
|
||||
{
|
||||
'name' => 'Business Trip to NYC',
|
||||
'started_at' => '2024-01-15T08:00:00Z',
|
||||
'ended_at' => '2024-01-18T20:00:00Z',
|
||||
'distance' => 1245.67,
|
||||
'created_at' => '2024-01-19T00:00:00Z',
|
||||
'updated_at' => '2024-01-19T00:00:00Z'
|
||||
},
|
||||
{
|
||||
'name' => 'Weekend Getaway',
|
||||
'started_at' => '2024-02-10T09:00:00Z',
|
||||
'ended_at' => '2024-02-12T18:00:00Z',
|
||||
'distance' => 456.78,
|
||||
'created_at' => '2024-02-13T00:00:00Z',
|
||||
'updated_at' => '2024-02-13T00:00:00Z'
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:service) { described_class.new(user, trips_data) }
|
||||
|
||||
before do
|
||||
# Mock the job enqueuing to avoid it interfering with tests
|
||||
allow(Trips::CalculateAllJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid trips data' do
|
||||
it 'creates new trips for the user' do
|
||||
expect { service.call }.to change { user.trips.count }.by(2)
|
||||
end
|
||||
|
||||
it 'creates trips with correct attributes' do
|
||||
service.call
|
||||
|
||||
business_trip = user.trips.find_by(name: 'Business Trip to NYC')
|
||||
expect(business_trip).to have_attributes(
|
||||
name: 'Business Trip to NYC',
|
||||
started_at: Time.parse('2024-01-15T08:00:00Z'),
|
||||
ended_at: Time.parse('2024-01-18T20:00:00Z'),
|
||||
distance: 1245
|
||||
)
|
||||
|
||||
weekend_trip = user.trips.find_by(name: 'Weekend Getaway')
|
||||
expect(weekend_trip).to have_attributes(
|
||||
name: 'Weekend Getaway',
|
||||
started_at: Time.parse('2024-02-10T09:00:00Z'),
|
||||
ended_at: Time.parse('2024-02-12T18:00:00Z'),
|
||||
distance: 456
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the number of trips created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
|
||||
it 'logs the import process' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 2 trips for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Trips import completed. Created: 2")
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with duplicate trips' do
|
||||
before do
|
||||
# Create an existing trip with same name and times
|
||||
user.trips.create!(
|
||||
name: 'Business Trip to NYC',
|
||||
started_at: Time.parse('2024-01-15T08:00:00Z'),
|
||||
ended_at: Time.parse('2024-01-18T20:00:00Z'),
|
||||
distance: 1000.0
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips duplicate trips' do
|
||||
expect { service.call }.to change { user.trips.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs when skipping duplicates' do
|
||||
allow(Rails.logger).to receive(:debug) # Allow any debug logs
|
||||
expect(Rails.logger).to receive(:debug).with("Trip already exists: Business Trip to NYC")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns only the count of newly created trips' do
|
||||
result = service.call
|
||||
expect(result).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid trip data' do
|
||||
let(:trips_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Trip', 'started_at' => '2024-01-15T08:00:00Z', 'ended_at' => '2024-01-18T20:00:00Z' },
|
||||
'invalid_data',
|
||||
{ 'name' => 'Another Valid Trip', 'started_at' => '2024-02-10T09:00:00Z', 'ended_at' => '2024-02-12T18:00:00Z' }
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid entries and imports valid ones' do
|
||||
expect { service.call }.to change { user.trips.count }.by(2)
|
||||
end
|
||||
|
||||
it 'returns the count of valid trips created' do
|
||||
result = service.call
|
||||
expect(result).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with validation errors' do
|
||||
let(:trips_data) do
|
||||
[
|
||||
{ 'name' => 'Valid Trip', 'started_at' => '2024-01-15T08:00:00Z', 'ended_at' => '2024-01-18T20:00:00Z' },
|
||||
{ 'started_at' => '2024-01-15T08:00:00Z', 'ended_at' => '2024-01-18T20:00:00Z' }, # missing name
|
||||
{ 'name' => 'Invalid Trip' } # missing required timestamps
|
||||
]
|
||||
end
|
||||
|
||||
it 'only creates valid trips' do
|
||||
expect { service.call }.to change { user.trips.count }.by(1)
|
||||
end
|
||||
|
||||
it 'logs validation errors' do
|
||||
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil trips data' do
|
||||
let(:trips_data) { nil }
|
||||
|
||||
it 'does not create any trips' do
|
||||
expect { service.call }.not_to change { user.trips.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-array trips data' do
|
||||
let(:trips_data) { 'invalid_data' }
|
||||
|
||||
it 'does not create any trips' do
|
||||
expect { service.call }.not_to change { user.trips.count }
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty trips data' do
|
||||
let(:trips_data) { [] }
|
||||
|
||||
it 'does not create any trips' do
|
||||
expect { service.call }.not_to change { user.trips.count }
|
||||
end
|
||||
|
||||
it 'logs the import process with 0 count' do
|
||||
expect(Rails.logger).to receive(:info).with("Importing 0 trips for user: #{user.email}")
|
||||
expect(Rails.logger).to receive(:info).with("Trips import completed. Created: 0")
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
result = service.call
|
||||
expect(result).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
297
spec/services/users/import_data_spec.rb
Normal file
297
spec/services/users/import_data_spec.rb
Normal file
@@ -0,0 +1,297 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ImportData, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:archive_path) { Rails.root.join('tmp', 'test_export.zip') }
|
||||
let(:service) { described_class.new(user, archive_path) }
|
||||
let(:import_directory) { Rails.root.join('tmp', "import_#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_1234567890") }
|
||||
|
||||
before do
|
||||
allow(Time).to receive(:current).and_return(Time.at(1234567890))
|
||||
allow(FileUtils).to receive(:mkdir_p)
|
||||
allow(FileUtils).to receive(:rm_rf)
|
||||
allow(File).to receive(:directory?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#import' do
|
||||
let(:sample_data) do
|
||||
{
|
||||
'counts' => {
|
||||
'areas' => 2,
|
||||
'places' => 3,
|
||||
'imports' => 1,
|
||||
'exports' => 1,
|
||||
'trips' => 2,
|
||||
'stats' => 1,
|
||||
'notifications' => 2,
|
||||
'visits' => 4,
|
||||
'points' => 1000
|
||||
},
|
||||
'settings' => { 'theme' => 'dark' },
|
||||
'areas' => [{ 'name' => 'Home', 'latitude' => '40.7128', 'longitude' => '-74.0060' }],
|
||||
'places' => [{ 'name' => 'Office', 'latitude' => '40.7589', 'longitude' => '-73.9851' }],
|
||||
'imports' => [{ 'name' => 'test.json', 'source' => 'owntracks' }],
|
||||
'exports' => [{ 'name' => 'export.json', 'status' => 'completed' }],
|
||||
'trips' => [{ 'name' => 'Trip to NYC', 'distance' => 100.5 }],
|
||||
'stats' => [{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 }],
|
||||
'notifications' => [{ 'title' => 'Test', 'content' => 'Test notification' }],
|
||||
'visits' => [{ 'name' => 'Work Visit', 'duration' => 3600 }],
|
||||
'points' => [{ 'latitude' => 40.7128, 'longitude' => -74.0060, 'timestamp' => 1234567890 }]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock ZIP file extraction
|
||||
zipfile_mock = double('ZipFile')
|
||||
allow(zipfile_mock).to receive(:each)
|
||||
allow(Zip::File).to receive(:open).with(archive_path).and_yield(zipfile_mock)
|
||||
|
||||
# Mock JSON loading and File operations
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(sample_data.to_json)
|
||||
|
||||
# Mock all import services
|
||||
allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true))
|
||||
allow(Users::ImportData::Areas).to receive(:new).and_return(double(call: 2))
|
||||
allow(Users::ImportData::Places).to receive(:new).and_return(double(call: 3))
|
||||
allow(Users::ImportData::Imports).to receive(:new).and_return(double(call: [1, 5]))
|
||||
allow(Users::ImportData::Exports).to receive(:new).and_return(double(call: [1, 2]))
|
||||
allow(Users::ImportData::Trips).to receive(:new).and_return(double(call: 2))
|
||||
allow(Users::ImportData::Stats).to receive(:new).and_return(double(call: 1))
|
||||
allow(Users::ImportData::Notifications).to receive(:new).and_return(double(call: 2))
|
||||
allow(Users::ImportData::Visits).to receive(:new).and_return(double(call: 4))
|
||||
allow(Users::ImportData::Points).to receive(:new).and_return(double(call: 1000))
|
||||
|
||||
# Mock notifications
|
||||
allow(::Notifications::Create).to receive(:new).and_return(double(call: true))
|
||||
|
||||
# Mock cleanup
|
||||
allow(service).to receive(:cleanup_temporary_files)
|
||||
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when import is successful' do
|
||||
it 'creates import directory' do
|
||||
expect(FileUtils).to receive(:mkdir_p).with(import_directory)
|
||||
|
||||
service.import
|
||||
end
|
||||
|
||||
it 'extracts the archive' do
|
||||
expect(Zip::File).to receive(:open).with(archive_path)
|
||||
|
||||
service.import
|
||||
end
|
||||
|
||||
it 'loads JSON data from extracted files' do
|
||||
expect(File).to receive(:exist?).with(import_directory.join('data.json'))
|
||||
expect(File).to receive(:read).with(import_directory.join('data.json'))
|
||||
|
||||
service.import
|
||||
end
|
||||
|
||||
it 'calls all import services in correct order' do
|
||||
expect(Users::ImportData::Settings).to receive(:new).with(user, sample_data['settings']).ordered
|
||||
expect(Users::ImportData::Areas).to receive(:new).with(user, sample_data['areas']).ordered
|
||||
expect(Users::ImportData::Places).to receive(:new).with(user, sample_data['places']).ordered
|
||||
expect(Users::ImportData::Imports).to receive(:new).with(user, sample_data['imports'], import_directory.join('files')).ordered
|
||||
expect(Users::ImportData::Exports).to receive(:new).with(user, sample_data['exports'], import_directory.join('files')).ordered
|
||||
expect(Users::ImportData::Trips).to receive(:new).with(user, sample_data['trips']).ordered
|
||||
expect(Users::ImportData::Stats).to receive(:new).with(user, sample_data['stats']).ordered
|
||||
expect(Users::ImportData::Notifications).to receive(:new).with(user, sample_data['notifications']).ordered
|
||||
expect(Users::ImportData::Visits).to receive(:new).with(user, sample_data['visits']).ordered
|
||||
expect(Users::ImportData::Points).to receive(:new).with(user, sample_data['points']).ordered
|
||||
|
||||
service.import
|
||||
end
|
||||
|
||||
it 'creates success notification with import stats' do
|
||||
expect(::Notifications::Create).to receive(:new).with(
|
||||
user: user,
|
||||
title: 'Data import completed',
|
||||
content: match(/1000 points.*4 visits.*3 places.*2 trips/),
|
||||
kind: :info
|
||||
)
|
||||
|
||||
service.import
|
||||
end
|
||||
|
||||
it 'cleans up temporary files' do
|
||||
expect(service).to receive(:cleanup_temporary_files).with(import_directory)
|
||||
|
||||
service.import
|
||||
end
|
||||
|
||||
it 'returns import statistics' do
|
||||
result = service.import
|
||||
|
||||
expect(result).to include(
|
||||
settings_updated: true,
|
||||
areas_created: 2,
|
||||
places_created: 3,
|
||||
imports_created: 1,
|
||||
exports_created: 1,
|
||||
trips_created: 2,
|
||||
stats_created: 1,
|
||||
notifications_created: 2,
|
||||
visits_created: 4,
|
||||
points_created: 1000,
|
||||
files_restored: 7
|
||||
)
|
||||
end
|
||||
|
||||
it 'logs expected counts if available' do
|
||||
allow(Rails.logger).to receive(:info) # Allow other log messages
|
||||
expect(Rails.logger).to receive(:info).with(/Expected entity counts from export:/)
|
||||
|
||||
service.import
|
||||
end
|
||||
end
|
||||
|
||||
context 'when JSON file is missing' do
|
||||
before do
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(false)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.import }.to raise_error(StandardError, 'Data file not found in archive: data.json')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when JSON is invalid' do
|
||||
before do
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return('invalid json')
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
end
|
||||
|
||||
it 'raises a JSON parse error' do
|
||||
expect { service.import }.to raise_error(StandardError, /Invalid JSON format in data file/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs during import' do
|
||||
let(:error_message) { 'Something went wrong' }
|
||||
|
||||
before do
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(sample_data.to_json)
|
||||
allow(Users::ImportData::Settings).to receive(:new).and_raise(StandardError, error_message)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
allow(::Notifications::Create).to receive(:new).and_return(double(call: true))
|
||||
end
|
||||
|
||||
it 'creates failure notification' do
|
||||
expect(::Notifications::Create).to receive(:new).with(
|
||||
user: user,
|
||||
title: 'Data import failed',
|
||||
content: "Your data import failed with error: #{error_message}. Please check the archive format and try again.",
|
||||
kind: :error
|
||||
)
|
||||
|
||||
expect { service.import }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'reports error via ExceptionReporter' do
|
||||
expect(ExceptionReporter).to receive(:call).with(
|
||||
an_instance_of(StandardError),
|
||||
'Data import failed'
|
||||
)
|
||||
|
||||
expect { service.import }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'still cleans up temporary files' do
|
||||
expect(service).to receive(:cleanup_temporary_files)
|
||||
|
||||
expect { service.import }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 're-raises the error' do
|
||||
expect { service.import }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when data sections are missing' do
|
||||
let(:minimal_data) { { 'settings' => { 'theme' => 'dark' } } }
|
||||
|
||||
before do
|
||||
# Reset JSON file mocking
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true)
|
||||
allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(minimal_data.to_json)
|
||||
|
||||
# Only expect Settings to be called
|
||||
allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true))
|
||||
allow(::Notifications::Create).to receive(:new).and_return(double(call: true))
|
||||
end
|
||||
|
||||
it 'only imports available sections' do
|
||||
expect(Users::ImportData::Settings).to receive(:new).with(user, minimal_data['settings'])
|
||||
expect(Users::ImportData::Areas).not_to receive(:new)
|
||||
expect(Users::ImportData::Places).not_to receive(:new)
|
||||
|
||||
service.import
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#cleanup_temporary_files' do
|
||||
context 'when directory exists' do
|
||||
before do
|
||||
allow(File).to receive(:directory?).and_return(true)
|
||||
allow(Rails.logger).to receive(:info)
|
||||
end
|
||||
|
||||
it 'removes the directory' do
|
||||
expect(FileUtils).to receive(:rm_rf).with(import_directory)
|
||||
|
||||
service.send(:cleanup_temporary_files, import_directory)
|
||||
end
|
||||
|
||||
it 'logs the cleanup' do
|
||||
expect(Rails.logger).to receive(:info).with("Cleaning up temporary import directory: #{import_directory}")
|
||||
|
||||
service.send(:cleanup_temporary_files, import_directory)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when cleanup fails' do
|
||||
before do
|
||||
allow(File).to receive(:directory?).and_return(true)
|
||||
allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied')
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
end
|
||||
|
||||
it 'reports error via ExceptionReporter but does not re-raise' do
|
||||
expect(ExceptionReporter).to receive(:call).with(
|
||||
an_instance_of(StandardError),
|
||||
'Failed to cleanup temporary files'
|
||||
)
|
||||
|
||||
expect { service.send(:cleanup_temporary_files, import_directory) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when directory does not exist' do
|
||||
before do
|
||||
allow(File).to receive(:directory?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not attempt cleanup' do
|
||||
expect(FileUtils).not_to receive(:rm_rf)
|
||||
|
||||
service.send(:cleanup_temporary_files, import_directory)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user